diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9a2d24f4..a5e5b65d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,11 @@ - +## 📋 Checklist -## 🧩 이슈 번호 - -- #이슈번호 +- [ ] 🔀 PR 제목의 형식을 잘 작성했나요? (e.g. `feat: 유저 조회 기능 구현`) +- [ ] 🏷️ 라벨, 프로젝트, 마일스톤은 등록했나요? +- [ ] 🧹 코드 스멜은 해결했나요? -## ✅ 작업 사항 +## 🧩 이슈 번호 -- [ ] 작업 내용 +- close #이슈번호 ## 👩‍💻 공유 포인트 및 논의 사항 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3109497d..17e57edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,46 +1,52 @@ name: ci on: - pull_request: - branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] jobs: - build: - name: build - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - with: - fetch-depth: 0 - - - name: JDK 17 셋업 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'corretto' - - - name: Gradle 캐싱 - 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: Gradle Grant 권한 부여 - run: chmod +x gradlew - - - name: SonarCloud 캐싱 - uses: actions/cache@v3 - with: - path: ~/.sonar/cache - key: ${{ runner.os }}-sonar - restore-keys: ${{ runner.os }}-sonar - - - name: 빌드 및 분석 - run: ./gradlew build jacocoTestReport sonar --info --stacktrace - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }} + build: + name: build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + submodules: true + token: ${{ secrets.MOABAM_SUBMODULE_KEY }} + + - name: JDK 17 셋업 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Gradle 캐싱 + 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: Gradle Grant 권한 부여 + run: chmod +x gradlew + + - name: 테스트용 MySQL 도커 컨테이너 실행 + run: | + sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33 + + - name: SonarCloud 캐싱 + uses: actions/cache@v3 + with: + path: ~/.sonar/cache + key: ${{ runner.os }}-sonar + restore-keys: ${{ runner.os }}-sonar + + - name: 빌드 및 분석 + run: ./gradlew build jacocoTestReport sonar --info --stacktrace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }} diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml new file mode 100644 index 00000000..cd5449bf --- /dev/null +++ b/.github/workflows/develop-cd.yml @@ -0,0 +1,175 @@ +name: develop-CD + +on: + push: + branches: [ "develop" ] + +permissions: + contents: write + +jobs: + move-files: + name: move-files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.MOABAM_SUBMODULE_KEY }} + + - name: Github Actions IP 획득 + id: ip + uses: haythem/public-ip@v1.3 + + - name: AWS Credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Github Actions IP 보안그룹 추가 + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: 디렉토리 생성 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + script: | + mkdir -p /home/ubuntu/moabam/ + + - name: Docker env 파일 생성 + run: + cp src/main/resources/config/dev.env ./infra/.env + + - name: 서버로 전송 기본 파일들 전송 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-prod.sh, infra/docker-compose-dev.yml" + target: "/home/ubuntu/moabam" + + - name: 파일 세팅 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + script: | + cd /home/ubuntu/moabam/infra + mv docker-compose-dev.yml docker-compose.yml + chmod +x ./scripts/deploy-dev.sh + chmod +x ./scripts/init-letsencrypt.sh + chmod +x ./scripts/init-nginx-converter.sh + chmod +x ./mysql/initdb.d/init.sql + chmod +x ./mysql/initdb.d/item-data.sql + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + deploy: + name: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.MOABAM_SUBMODULE_KEY }} + + - name: JDK 17 셋업 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Gradle 캐싱 + 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: Gradle Grant 권한 부여 + run: chmod +x gradlew + + - name: 테스트용 MySQL 도커 컨테이너 실행 + run: | + sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33 + + - name: Gradle 빌드 + uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 + with: + arguments: build + + - name: 멀티플랫폼 위한 Docker Buildx 설정 + uses: docker/setup-buildx-action@v2 + + - name: Docker Hub 로그인 + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Docker Hub 빌드하고 푸시 + uses: docker/build-push-action@v4 + with: + context: . + file: ./infra/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} + build-args: | + "SPRING_ACTIVE_PROFILES=dev" + platforms: | + linux/amd64 + linux/arm64 + + - name: Github Actions IP 획득 + id: ip + uses: haythem/public-ip@v1.3 + + - name: AWS Credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Github Actions IP 보안그룹 추가 + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: EC2 서버에 배포 + uses: appleboy/ssh-action@master + id: deploy-dev + if: contains(github.ref, 'dev') + with: + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + source: "./infra/docker-compose-dev.yml" + script: | + cd /home/ubuntu/moabam/infra + echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin + ./scripts/deploy-dev.sh + docker rm `docker ps -a -q` + docker rmi $(docker images -aq) + echo "### 배포 완료 ###" + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_DEV_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml new file mode 100644 index 00000000..d00dca7c --- /dev/null +++ b/.github/workflows/prod-cd.yml @@ -0,0 +1,173 @@ +name: prod-CD + +on: + push: + branches: [ "main" ] + +permissions: + contents: write + +jobs: + move-files: + name: move-files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.MOABAM_SUBMODULE_KEY }} + + - name: Github Actions IP 획득 + id: ip + uses: haythem/public-ip@v1.3 + + - name: AWS Credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Github Actions IP 보안그룹 추가 + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: 디렉토리 생성 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_PROD_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }} + script: | + mkdir -p /home/ubuntu/moabam/ + + - name: Docker env 파일 생성 + run: + cp src/main/resources/config/prod.env ./infra/.env + + - name: 서버로 전송 기본 파일들 전송 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_PROD_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }} + source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-dev.sh, infra/docker-compose-prod.yml" + target: "/home/ubuntu/moabam" + + - name: 파일 세팅 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_PROD_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }} + script: | + cd /home/ubuntu/moabam/infra + mv docker-compose-prod.yml docker-compose.yml + chmod +x ./scripts/deploy-prod.sh + chmod +x ./scripts/init-letsencrypt.sh + chmod +x ./scripts/init-nginx-converter.sh + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + deploy: + name: deploy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + submodules: true + token: ${{ secrets.MOABAM_SUBMODULE_KEY }} + + - name: JDK 17 셋업 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'corretto' + + - name: Gradle 캐싱 + 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: Gradle Grant 권한 부여 + run: chmod +x gradlew + + - name: 테스트용 MySQL 도커 컨테이너 실행 + run: | + sudo docker run -d -p 3305:3306 --env MYSQL_DATABASE=moabam --env MYSQL_ROOT_PASSWORD=1234 mysql:8.0.33 + + - name: Gradle 빌드 + uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 + with: + arguments: build + + - name: 멀티플랫폼 위한 Docker Buildx 설정 + uses: docker/setup-buildx-action@v2 + + - name: Docker Hub 로그인 + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKER_HUB_USERNAME }} + password: ${{ secrets.DOCKER_HUB_TOKEN }} + + - name: Docker Hub 빌드하고 푸시 + uses: docker/build-push-action@v4 + with: + context: . + file: ./infra/Dockerfile + push: true + tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} + build-args: | + "SPRING_ACTIVE_PROFILES=prod" + platforms: | + linux/amd64 + linux/arm64 + + - name: Github Actions IP 획득 + id: ip + uses: haythem/public-ip@v1.3 + + - name: AWS Credentials 설정 + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Github Actions IP 보안그룹 추가 + run: | + aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: EC2 서버에 배포 + uses: appleboy/ssh-action@master + id: deploy-prod + if: contains(github.ref, 'main') + with: + host: ${{ secrets.EC2_PROD_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }} + source: "./infra/docker-compose-prod.yml" + script: | + cd /home/ubuntu/moabam/infra + echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin + ./scripts/deploy-prod.sh + docker rm `docker ps -a -q` + docker rmi $(docker images -aq) + echo "### 배포 완료 ###" + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_PROD_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 diff --git a/.gitignore b/.gitignore index df8fb3c3..1bd49ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,6 @@ gradle-app.setting logs/ application-*.yml src/main/resources/config +!application-test.yml +src/main/generated +dump.rdb diff --git a/build.gradle b/build.gradle index 1fc75afe..c7f8c37e 100644 --- a/build.gradle +++ b/build.gradle @@ -5,6 +5,7 @@ plugins { id 'org.sonarqube' version '4.4.1.3373' id 'jacoco' id 'checkstyle' + id 'org.asciidoctor.jvm.convert' version '3.3.2' } group = 'com.moabam' @@ -14,7 +15,21 @@ java { sourceCompatibility = '17' } +ext { + snippetsDir = file('build/generated-snippets') +} + +def querydslSrcDir = 'src/main/generated' +clean { + delete file(querydslSrcDir) +} +tasks.withType(JavaCompile) { + options.generatedSourceOutputDirectory = file(querydslSrcDir) +} + configurations { + asciidoctorExtensions + compileOnly { extendsFrom annotationProcessor } @@ -43,6 +58,7 @@ dependencies { // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0' // Querydsl implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' @@ -52,6 +68,51 @@ dependencies { // H2 implementation 'com.h2database:h2' + + // Configuration Binding + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + // Apache Commons Lang 3 + implementation 'org.apache.commons:commons-lang3:3.13.0' + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Embedded-Redis + implementation group: 'it.ozimov', name: 'embedded-redis', version: '0.7.2' + + // Firebase Admin + implementation 'com.google.firebase:firebase-admin:9.2.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' + + // JSON parser + implementation 'org.json:json:20230618' + + // Asciidoctor + asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' + + // RestDocs + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' + + // S3 + implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2") + implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' + + // Webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Slack Webhook + implementation 'net.gpedro.integrations.slack:slack-webhook:1.4.0' + + // Logback Slack Appender + implementation 'com.github.maricn:logback-slack-appender:1.6.1' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } tasks.named('test') { @@ -73,17 +134,21 @@ jacocoTestReport { afterEvaluate { classDirectories.setFrom( - files(classDirectories.files.collect { - fileTree(dir: it, excludes: [ - "**/*Application*", - "**/*Config*", - "**/*Request*", - "**/*Response*", - "**/*Exception*", - "**/*Mapper*", - "**/*ErrorMessage*", - ] + Qdomains) - }) + files(classDirectories.files.collect { + fileTree(dir: it, excludes: [ + "**/*Application*", + "**/*Config*", + "**/*Request*", + "**/*Response*", + "**/*Exception*", + "**/*Mapper*", + "**/*ErrorMessage*", + "**/*DynamicQuery*", + "**/*BaseTimeEntity*", + "**/*HealthCheckController*", + "**/*S3Manager*", + ] + Qdomains) + }) ) } } @@ -112,8 +177,37 @@ sonar { property "sonar.host.url", "https://sonarcloud.io" property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' property 'sonar.coverage.exclusions', '**/test/**, **/Q*.java, **/*Doc*.java, **/resources/** ' + - ',**/*Application*.java , **/*Config*.java, **/*Request*.java, **/*Response*.java ,**/*Exception*.java ' + - ',**/*ErrorMessage*.java, **/*Mapper*.java' + ',**/*Application*.java , **/*Config*.java, **/*Request*.java, **/*Response*.java ,**/*Exception*.java ' + + ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController* ' + + ', **/*S3Manager*.java' property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } + +test { + outputs.dir snippetsDir +} + +asciidoctor { + configurations 'asciidoctorExtensions' + inputs.dir snippetsDir + dependsOn test +} + +asciidoctor.doFirst { + delete file('src/main/resources/static/docs') +} + +tasks.register('copyDocument', Copy) { + dependsOn asciidoctor + from file("build/docs/asciidoc") + into file("src/main/resources/static/docs") +} + +bootJar { + dependsOn copyDocument +} + +build { + dependsOn copyDocument +} diff --git a/config/naver-intellij-formatter-custom.xml b/config/naver-intellij-formatter-custom.xml index 26f28954..dc4c4a2e 100644 --- a/config/naver-intellij-formatter-custom.xml +++ b/config/naver-intellij-formatter-custom.xml @@ -1,74 +1,75 @@ - - - \ No newline at end of file diff --git a/infra/Dockerfile b/infra/Dockerfile new file mode 100644 index 00000000..c7fb704b --- /dev/null +++ b/infra/Dockerfile @@ -0,0 +1,8 @@ +FROM amazoncorretto:17 + +ARG SPRING_ACTIVE_PROFILES +ENV SPRING_ACTIVE_PROFILES ${SPRING_ACTIVE_PROFILES} + +COPY build/libs/moabam-server-0.0.1-SNAPSHOT.jar moabam.jar + +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=${SPRING_ACTIVE_PROFILES}", "/moabam.jar"] diff --git a/infra/docker-compose-dev.yml b/infra/docker-compose-dev.yml new file mode 100644 index 00000000..86ec3530 --- /dev/null +++ b/infra/docker-compose-dev.yml @@ -0,0 +1,77 @@ +version: '3.7' + +services: + nginx: + image: nginx:latest + container_name: nginx + platform: linux/arm64/v8 + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + - ../logs/nginx:/var/log/nginx + certbot: + image: certbot/certbot:latest + container_name: certbot + platform: linux/arm64 + restart: unless-stopped + volumes: + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + moabam-blue: + image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG} + container_name: ${BLUE_CONTAINER} + restart: unless-stopped + expose: + - ${SERVER_PORT} + depends_on: + - redis + - mysql + environment: + SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES} + moabam-green: + image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG} + container_name: ${GREEN_CONTAINER} + restart: unless-stopped + expose: + - ${SERVER_PORT} + depends_on: + - redis + - mysql + environment: + SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES} + redis: + image: redis:alpine + container_name: redis + platform: linux/arm64 + restart: always + command: redis-server + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + mysql: + image: mysql:8.0.33 + container_name: mysql + platform: linux/arm64/v8 + restart: always + ports: + - "3306:3306" + environment: + MYSQL_DATABASE: ${DEV_MYSQL_DATABASE} + MYSQL_USERNAME: ${DEV_MYSQL_USERNAME} + MYSQL_ROOT_PASSWORD: ${DEV_MYSQL_PASSWORD} + TZ: Asia/Seoul + command: + - --character-set-server=utf8mb4 + - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake + volumes: + - ./data/mysql:/var/lib/mysql + - ./mysql/initdb.d:/docker-entrypoint-initdb.d diff --git a/infra/docker-compose-prod.yml b/infra/docker-compose-prod.yml new file mode 100644 index 00000000..8cf816fa --- /dev/null +++ b/infra/docker-compose-prod.yml @@ -0,0 +1,56 @@ +version: '3.7' + +services: + nginx: + image: nginx:latest + container_name: nginx + platform: linux/arm64/v8 + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf + - ./nginx/conf.d:/etc/nginx/conf.d + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + - ../logs/nginx:/var/log/nginx + certbot: + image: certbot/certbot:latest + container_name: certbot + platform: linux/arm64 + restart: unless-stopped + volumes: + - ./nginx/certbot/conf:/etc/letsencrypt + - ./nginx/certbot/www:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + moabam-blue: + image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG} + container_name: ${BLUE_CONTAINER} + restart: unless-stopped + expose: + - ${SERVER_PORT} + depends_on: + - redis + environment: + SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES} + moabam-green: + image: ${DOCKER_HUB_USERNAME}/${DOCKER_HUB_REPOSITORY}:${DOCKER_HUB_TAG} + container_name: ${GREEN_CONTAINER} + restart: unless-stopped + expose: + - ${SERVER_PORT} + depends_on: + - redis + environment: + SPRING_ACTIVE_PROFILES: ${SPRING_ACTIVE_PROFILES} + redis: + image: redis:alpine + container_name: redis + platform: linux/arm64 + restart: always + command: redis-server + ports: + - "6379:6379" + volumes: + - ./data/redis:/data diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql new file mode 100644 index 00000000..a6edd500 --- /dev/null +++ b/infra/mysql/initdb.d/init.sql @@ -0,0 +1,259 @@ +use moabam_dev; + +create table admin +( + id bigint not null auto_increment, + nickname varchar(255) not null unique, + social_id varchar(255) unique, + role enum ('ADMIN','BLACK','USER') default 'ADMIN' not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table badge +( + id bigint not null auto_increment, + member_id bigint not null, + type enum ('BIRTH','LEVEL10','LEVEL50') not null, + created_at datetime(6) not null, + primary key (id) +); + +create table bug_history +( + id bigint not null auto_increment, + member_id bigint not null, + payment_id bigint, + bug_type enum ('GOLDEN','MORNING','NIGHT') not null, + action_type enum ('CHARGE','COUPON','REFUND','REWARD','USE') not null, + quantity integer not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table certification +( + id bigint not null auto_increment, + routine_id bigint not null, + member_id bigint not null, + image varchar(255) not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table coupon +( + id bigint not null auto_increment, + name varchar(20) not null unique, + point integer default 1 not null, + description varchar(50) default '', + type enum ('DISCOUNT','GOLDEN','MORNING','NIGHT') not null, + max_count integer default 1 not null, + start_at date not null unique, + open_at date not null, + admin_id bigint not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table coupon_wallet +( + id bigint not null auto_increment, + member_id bigint not null, + coupon_id bigint not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table daily_member_certification +( + id bigint not null auto_increment, + member_id bigint not null, + room_id bigint not null, + participant_id bigint, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table daily_room_certification +( + id bigint not null auto_increment, + room_id bigint not null, + certified_at date not null, + primary key (id) +); + +create table inventory +( + id bigint not null auto_increment, + member_id bigint not null, + item_id bigint not null, + is_default bit default false not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id), + index idx_member_id (member_id) +); + +create table item +( + id bigint not null auto_increment, + type enum ('MORNING','NIGHT') not null, + category enum ('SKIN') not null, + name varchar(255) not null, + awake_image varchar(255) not null, + sleep_image varchar(255) not null, + bug_price integer default 0 not null, + golden_bug_price integer default 0 not null, + unlock_level integer default 1 not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table member +( + id bigint not null auto_increment, + social_id varchar(255) not null unique, + nickname varchar(255) unique, + intro varchar(30), + profile_image varchar(255) not null, + morning_image varchar(255) not null, + night_image varchar(255) not null, + total_certify_count bigint default 0 not null, + report_count integer default 0 not null, + current_morning_count integer default 0 not null, + current_night_count integer default 0 not null, + morning_bug integer default 0 not null, + night_bug integer default 0 not null, + golden_bug integer default 0 not null, + role enum ('ADMIN','BLACK','USER') default 'USER' not null, + deleted_at datetime(6), + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table participant +( + id bigint not null auto_increment, + room_id bigint, + member_id bigint not null, + is_manager bit, + certify_count integer, + deleted_at datetime(6), + deleted_room_title varchar(30), + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table payment +( + id bigint not null auto_increment, + member_id bigint not null, + product_id bigint not null, + coupon_wallet_id bigint, + order_id varchar(255), + order_name varchar(255) not null, + total_amount integer not null, + discount_amount integer not null, + payment_key varchar(255), + status enum ('ABORTED','CANCELED','DONE','EXPIRED','IN_PROGRESS','READY') not null, + created_at datetime(6) not null, + requested_at datetime(6), + approved_at datetime(6), + primary key (id), + index idx_order_id (order_id) +); + +create table product +( + id bigint not null auto_increment, + type enum ('BUG') default 'BUG' not null, + name varchar(255) not null, + price integer not null, + quantity integer default 1 not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table report +( + id bigint not null auto_increment, + reporter_id bigint not null, + reported_member_id bigint not null, + room_id bigint, + certification_id bigint, + description varchar(255), + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table room +( + id bigint not null auto_increment, + title varchar(20) not null, + password varchar(8), + level integer default 0 not null, + exp integer default 0 not null, + room_type enum ('MORNING','NIGHT'), + certify_time integer not null, + current_user_count integer not null, + max_user_count integer not null, + announcement varchar(100), + room_image varchar(500), + manager_nickname varchar(30), + deleted_at datetime(6), + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +create table routine +( + id bigint not null auto_increment, + room_id bigint, + content varchar(20) not null, + created_at datetime(6) not null, + updated_at datetime(6), + primary key (id) +); + +alter table bug_history + add foreign key (payment_id) references payment (id); + +alter table certification + add foreign key (routine_id) references routine (id); + +alter table coupon_wallet + add foreign key (coupon_id) references coupon (id); + +alter table daily_member_certification + add foreign key (participant_id) references participant (id); + +alter table inventory + add foreign key (item_id) references item (id); + +alter table participant + add foreign key (room_id) references room (id); + +alter table payment + add foreign key (product_id) references product (id); + +alter table report + add foreign key (certification_id) references certification (id); + +alter table report + add foreign key (room_id) references room (id); + +alter table routine + add foreign key (room_id) references room (id); diff --git a/infra/mysql/initdb.d/item-data.sql b/infra/mysql/initdb.d/item-data.sql new file mode 100644 index 00000000..568e8f0f --- /dev/null +++ b/infra/mysql/initdb.d/item-data.sql @@ -0,0 +1,48 @@ +insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at) +values ('MORNING', 'SKIN', '오목눈이 알', 'https://image.moabam.com/moabam/skins/omok/default/egg.png', + 'https://image.moabam.com/moabam/skins/omok/default/egg.png', 0, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at) +values ('NIGHT', 'SKIN', '부엉이 알', 'https://image.moabam.com/moabam/skins/owl/default/egg.png', + 'https://image.moabam.com/moabam/skins/owl/default/egg.png', 0, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at) +values ('MORNING', 'SKIN', '오목눈이', 'https://image.moabam.com/moabam/skins/omok/default/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/default/eyes-closed.png', 1, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, unlock_level, created_at) +values ('NIGHT', 'SKIN', '부엉이', 'https://image.moabam.com/moabam/skins/owl/default/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/default/eyes-closed.png', 1, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('MORNING', 'SKIN', '안경 오목눈이', 'https://image.moabam.com/moabam/skins/omok/glasses/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/glasses/eyes-closed.png', 10, 5, 5, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('NIGHT', 'SKIN', '안경 부엉이', 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-closed.png', 10, 5, 5, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('MORNING', 'SKIN', '목도리 오목눈이', 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed.png', 20, 10, 10, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('NIGHT', 'SKIN', '목도리 부엉이', 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-closed.png', 20, 10, 10, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('MORNING', 'SKIN', '산타 오목눈이', 'https://image.moabam.com/moabam/skins/omok/santa/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/santa/eyes-closed.png', 30, 15, 15, current_time()); + +insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) +values ('NIGHT', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed.png', 30, 15, 15, current_time()); + +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 5', 3000, 5, current_time()); + +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 15', 7000, 15, current_time()); + +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 25', 9900, 25, current_time()); diff --git a/infra/nginx/conf.d/header.conf b/infra/nginx/conf.d/header.conf new file mode 100644 index 00000000..59deea39 --- /dev/null +++ b/infra/nginx/conf.d/header.conf @@ -0,0 +1,9 @@ +proxy_pass_header Server; +proxy_http_version 1.1; +proxy_set_header Host $http_host; +proxy_set_header Connection $connection_upgrade; +proxy_set_header Upgrade $http_upgrade; + +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; diff --git a/infra/nginx/mime.types b/infra/nginx/mime.types new file mode 100644 index 00000000..7c7cdef2 --- /dev/null +++ b/infra/nginx/mime.types @@ -0,0 +1,96 @@ +types { + text/html html htm shtml; + text/css css; + text/xml xml; + image/gif gif; + image/jpeg jpeg jpg; + application/javascript js; + application/atom+xml atom; + application/rss+xml rss; + + text/mathml mml; + text/plain txt; + text/vnd.sun.j2me.app-descriptor jad; + text/vnd.wap.wml wml; + text/x-component htc; + + image/png png; + image/svg+xml svg svgz; + image/tiff tif tiff; + image/vnd.wap.wbmp wbmp; + image/webp webp; + image/x-icon ico; + image/x-jng jng; + image/x-ms-bmp bmp; + + font/woff woff; + font/woff2 woff2; + + application/java-archive jar war ear; + application/json json; + application/mac-binhex40 hqx; + application/msword doc; + application/pdf pdf; + application/postscript ps eps ai; + application/rtf rtf; + application/vnd.apple.mpegurl m3u8; + application/vnd.google-earth.kml+xml kml; + application/vnd.google-earth.kmz kmz; + application/vnd.ms-excel xls; + application/vnd.ms-fontobject eot; + application/vnd.ms-powerpoint ppt; + application/vnd.oasis.opendocument.graphics odg; + application/vnd.oasis.opendocument.presentation odp; + application/vnd.oasis.opendocument.spreadsheet ods; + application/vnd.oasis.opendocument.text odt; + application/vnd.openxmlformats-officedocument.presentationml.presentation + pptx; + application/vnd.openxmlformats-officedocument.spreadsheetml.sheet + xlsx; + application/vnd.openxmlformats-officedocument.wordprocessingml.document + docx; + application/vnd.wap.wmlc wmlc; + application/x-7z-compressed 7z; + application/x-cocoa cco; + application/x-java-archive-diff jardiff; + application/x-java-jnlp-file jnlp; + application/x-makeself run; + application/x-perl pl pm; + application/x-pilot prc pdb; + application/x-rar-compressed rar; + application/x-redhat-package-manager rpm; + application/x-sea sea; + application/x-shockwave-flash swf; + application/x-stuffit sit; + application/x-tcl tcl tk; + application/x-x509-ca-cert der pem crt; + application/x-xpinstall xpi; + application/xhtml+xml xhtml; + application/xspf+xml xspf; + application/zip zip; + + application/octet-stream bin exe dll; + application/octet-stream deb; + application/octet-stream dmg; + application/octet-stream iso img; + application/octet-stream msi msp msm; + + audio/midi mid midi kar; + audio/mpeg mp3; + audio/ogg ogg; + audio/x-m4a m4a; + audio/x-realaudio ra; + + video/3gpp 3gpp 3gp; + video/mp2t ts; + video/mp4 mp4; + video/mpeg mpeg mpg; + video/quicktime mov; + video/webm webm; + video/x-flv flv; + video/x-m4v m4v; + video/x-mng mng; + video/x-ms-asf asx asf; + video/x-ms-wmv wmv; + video/x-msvideo avi; +} diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf new file mode 100644 index 00000000..73220f14 --- /dev/null +++ b/infra/nginx/nginx.conf @@ -0,0 +1,32 @@ +worker_processes auto; + +events { + use epoll; + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + client_max_body_size 10M; + + send_timeout 15s; + resolver_timeout 5s; + + server_tokens off; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + include conf.d/header.conf; + + log_format main '$remote_addr $remote_user "$request" ' + '$status $body_bytes_sent "$http_referer" "$request_time" ' + '"$http_user_agent" '; + + include conf.d/upstream.conf; + include conf.d/http-server.conf; + include conf.d/ssl-server.conf; +} diff --git a/infra/nginx/templates/http-server.template b/infra/nginx/templates/http-server.template new file mode 100644 index 00000000..f4c91d91 --- /dev/null +++ b/infra/nginx/templates/http-server.template @@ -0,0 +1,13 @@ +server { + listen 80; + server_name ${SERVER_DOMAIN}; + + location / { + return 301 https://$http_host$request_uri; + } + + location /.well-known/acme-challenge/ { + allow all; + root /var/www/certbot; + } +} diff --git a/infra/nginx/templates/ssl-server.template b/infra/nginx/templates/ssl-server.template new file mode 100644 index 00000000..46db85fb --- /dev/null +++ b/infra/nginx/templates/ssl-server.template @@ -0,0 +1,19 @@ +server { + listen 443 ssl; + server_name ${SERVER_DOMAIN}; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log error; + + location ^~ /actuator { + return 404; + } + + ssl_certificate /etc/letsencrypt/live/${SERVER_DOMAIN}/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/${SERVER_DOMAIN}/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://backend; + } +} diff --git a/infra/nginx/templates/upstream.template b/infra/nginx/templates/upstream.template new file mode 100644 index 00000000..d32d6266 --- /dev/null +++ b/infra/nginx/templates/upstream.template @@ -0,0 +1,4 @@ +upstream backend { + server ${BLUE_CONTAINER}:${SERVER_PORT}; + keepalive 1024; +} diff --git a/infra/scripts/deploy-dev.sh b/infra/scripts/deploy-dev.sh new file mode 100644 index 00000000..cc6debda --- /dev/null +++ b/infra/scripts/deploy-dev.sh @@ -0,0 +1,119 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/infra/.env ]; then + source /home/ubuntu/moabam/infra/.env +fi + +if [ $(docker ps | grep -c "nginx") -eq 0 ]; then + echo "### nginx 시작 ###" + docker-compose up -d nginx +else + echo "-------------------------------------------" + echo "nginx 이미 실행 중 입니다." + echo "-------------------------------------------" +fi + +echo +echo + +if [ $(docker ps | grep -c "redis") -eq 0 ]; then + echo "### redis 시작 ###" + docker-compose up -d redis +else + echo "-------------------------------------------" + echo "redis 이미 실행 중 입니다." + echo "-------------------------------------------" +fi + +echo +echo + +if [ $(docker ps | grep -c "mysql") -eq 0 ]; then + echo "### mysql 시작 ###" + docker-compose up -d mysql +else + echo "-------------------------------------------" + echo "mysql 이미 실행 중 입니다." + echo "-------------------------------------------" +fi + +echo +echo + +echo +echo "### springboot blue-green 무중단 배포 시작 ###" +echo + +IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER}) +NGINX_CONF="/home/ubuntu/moabam/infra/nginx/nginx.conf" +UPSTREAM_CONF="/home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf" + +if [ -n "$IS_BLUE" ]; then + echo "### BLUE => GREEN ###" + echo "1. ${GREEN_CONTAINER} 이미지 가져오고 실행" + docker-compose pull moabam-green + docker-compose up -d moabam-green + + attempt=1 + while [ $attempt -le 24 ]; do + echo "2. ${GREEN_CONTAINER} health check (Attempt: $attempt)" + sleep 5 + REQUEST=$(docker exec nginx curl http://${GREEN_CONTAINER}:${SERVER_PORT}) + + if [ -n "$REQUEST" ]; then + echo "${GREEN_CONTAINER} health check 성공" + sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $UPSTREAM_CONF + echo "3. nginx 설정파일 reload" + docker exec nginx service nginx reload + echo "4. ${BLUE_CONTAINER} 컨테이너 종료" + docker-compose stop moabam-blue + + echo "5. ${GREEN_CONTAINER} 배포 성공" + break; + fi + + if [ $attempt -eq 24 ]; then + echo "${GREEN_CONTAINER} 배포 실패 !!" + + docker-compose stop moabam-green + + exit 1; + fi + + attempt=$((attempt+1)) + done; +else + echo "### GREEN => BLUE ###" + echo "1. ${BLUE_CONTAINER} 이미지 가져오고 실행" + docker-compose pull moabam-blue + docker-compose up -d moabam-blue + + attempt=1 + while [ $attempt -le 24 ]; do + echo "2. ${BLUE_CONTAINER} health check (Attempt: $attempt)" + sleep 5 + REQUEST=$(docker exec nginx curl http://${BLUE_CONTAINER}:${SERVER_PORT}) + + if [ -n "$REQUEST" ]; then + echo "${BLUE_CONTAINER} health check 성공" + sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $UPSTREAM_CONF + echo "3. nginx 설정파일 reload" + docker exec nginx service nginx reload + echo "4. ${GREEN_CONTAINER} 컨테이너 종료" + docker-compose stop moabam-green + + echo "5. ${BLUE_CONTAINER} 배포 성공" + break; + fi + + if [ $attempt -eq 24 ]; then + echo "${BLUE_CONTAINER} 배포 실패 !!" + + docker-compose stop moabam-blue + exit 1; + fi + + attempt=$((attempt+1)) + done; +fi diff --git a/infra/scripts/deploy-prod.sh b/infra/scripts/deploy-prod.sh new file mode 100644 index 00000000..b9933fc0 --- /dev/null +++ b/infra/scripts/deploy-prod.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/infra/.env ]; then + source /home/ubuntu/moabam/infra/.env +fi + +if [ $(docker ps | grep -c "nginx") -eq 0 ]; then + echo "### nginx 시작 ###" + docker-compose up -d nginx +else + echo "-------------------------------------------" + echo "nginx 이미 실행 중 입니다." + echo "-------------------------------------------" +fi + +echo +echo + +if [ $(docker ps | grep -c "redis") -eq 0 ]; then + echo "### redis 시작 ###" + docker-compose up -d redis +else + echo "-------------------------------------------" + echo "redis 이미 실행 중 입니다." + echo "-------------------------------------------" +fi + +echo +echo + +echo +echo "### springboot blue-green 무중단 배포 시작 ###" +echo + +IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER}) +NGINX_CONF="/home/ubuntu/moabam/infra/nginx/nginx.conf" +UPSTREAM_CONF="/home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf" + +if [ -n "$IS_BLUE" ]; then + echo "### BLUE => GREEN ###" + echo "1. ${GREEN_CONTAINER} 이미지 가져오고 실행" + docker-compose pull moabam-green + docker-compose up -d moabam-green + + attempt=1 + while [ $attempt -le 24 ]; do + echo "2. ${GREEN_CONTAINER} health check (Attempt: $attempt)" + sleep 5 + REQUEST=$(docker exec nginx curl http://${GREEN_CONTAINER}:${SERVER_PORT}) + + if [ -n "$REQUEST" ]; then + echo "${GREEN_CONTAINER} health check 성공" + sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $UPSTREAM_CONF + echo "3. nginx 설정파일 reload" + docker exec nginx service nginx reload + echo "4. ${BLUE_CONTAINER} 컨테이너 종료" + docker-compose stop moabam-blue + + echo "5. ${GREEN_CONTAINER} 배포 성공" + break; + fi + + if [ $attempt -eq 24 ]; then + echo "${GREEN_CONTAINER} 배포 실패 !!" + + docker-compose stop moabam-green + + exit 1; + fi + + attempt=$((attempt+1)) + done; +else + echo "### GREEN => BLUE ###" + echo "1. ${BLUE_CONTAINER} 이미지 가져오고 실행" + docker-compose pull moabam-blue + docker-compose up -d moabam-blue + + attempt=1 + while [ $attempt -le 24 ]; do + echo "2. ${BLUE_CONTAINER} health check (Attempt: $attempt)" + sleep 5 + REQUEST=$(docker exec nginx curl http://${BLUE_CONTAINER}:${SERVER_PORT}) + + if [ -n "$REQUEST" ]; then + echo "${BLUE_CONTAINER} health check 성공" + sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $UPSTREAM_CONF + echo "3. nginx 설정파일 reload" + docker exec nginx service nginx reload + echo "4. ${GREEN_CONTAINER} 컨테이너 종료" + docker-compose stop moabam-green + + echo "5. ${BLUE_CONTAINER} 배포 성공" + break; + fi + + if [ $attempt -eq 24 ]; then + echo "${BLUE_CONTAINER} 배포 실패 !!" + + docker-compose stop moabam-blue + exit 1; + fi + + attempt=$((attempt+1)) + done; +fi diff --git a/infra/scripts/init-letsencrypt.sh b/infra/scripts/init-letsencrypt.sh new file mode 100644 index 00000000..1031fd25 --- /dev/null +++ b/infra/scripts/init-letsencrypt.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/infra/.env ]; then + source /home/ubuntu/moabam/infra/.env +fi + +if ! [ -x "$(command -v docker-compose)" ]; then + echo 'Error: docker-compose is not installed.' >&2 + exit 1 +fi + +domains="${SERVER_DOMAIN}" +rsa_key_size=4096 +data_path="/home/ubuntu/moabam/infra/nginx/certbot" +email="${MY_EMAIL}" # Adding a valid address is strongly recommended +staging=1 # Set to 1 if you're testing your setup to avoid hitting request limits + +if [ -d "$data_path" ]; then + read -p "Existing data found for $domains. Continue and replace existing certificate? (y/N) " decision + if [ "$decision" != "Y" ] && [ "$decision" != "y" ]; then + exit + fi +fi + + +if [ ! -e "$data_path/conf/options-ssl-nginx.conf" ] || [ ! -e "$data_path/conf/ssl-dhparams.pem" ]; then + echo "### Downloading recommended TLS parameters ..." + mkdir -p "$data_path/conf" + sudo chmod 777 "$data_path/conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot-nginx/certbot_nginx/_internal/tls_configs/options-ssl-nginx.conf > "$data_path/conf/options-ssl-nginx.conf" + curl -s https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem > "$data_path/conf/ssl-dhparams.pem" + echo +fi + +echo "### Creating dummy certificate for $domains ..." +path="/etc/letsencrypt/live/$domains" +sudo mkdir -p "$data_path/conf/live/$domains" +docker-compose run --rm --entrypoint "\ + openssl req -x509 -nodes -newkey rsa:$rsa_key_size -days 1\ + -keyout '$path/privkey.pem' \ + -out '$path/fullchain.pem' \ + -subj '/CN=localhost'" certbot +echo + + +echo "### Starting nginx ..." +docker-compose up --force-recreate -d nginx +echo + +echo "### Deleting dummy certificate for $domains ..." +docker-compose run --rm --entrypoint "\ + rm -Rf /etc/letsencrypt/live/$domains && \ + rm -Rf /etc/letsencrypt/archive/$domains && \ + rm -Rf /etc/letsencrypt/renewal/$domains.conf" certbot +echo + + +echo "### Requesting Let's Encrypt certificate for $domains ..." +#Join $domains to -d args +domain_args="" +for domain in "${domains[@]}"; do + domain_args="$domain_args -d $domain" +done + +# Select appropriate email arg +case "$email" in + "") email_arg="--register-unsafely-without-email" ;; + *) email_arg="--email $email" ;; +esac + +# Enable staging mode if needed +if [ $staging != "0" ]; then staging_arg="--staging"; fi + +docker-compose run --rm --entrypoint "\ + certbot certonly --webroot -w /var/www/certbot \ + $staging_arg \ + $email_arg \ + $domain_args \ + --rsa-key-size $rsa_key_size \ + --agree-tos \ + --force-renewal" certbot +echo + +echo "### Reloading nginx ..." +docker-compose exec nginx nginx -s reload diff --git a/infra/scripts/init-nginx-converter.sh b/infra/scripts/init-nginx-converter.sh new file mode 100644 index 00000000..861b2aeb --- /dev/null +++ b/infra/scripts/init-nginx-converter.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/infra/.env ]; then + source /home/ubuntu/moabam/infra/.env +fi + +export SERVER_DOMAIN=${SERVER_DOMAIN} +export SERVER_PORT=${SERVER_PORT} +export BLUE_CONTAINER=${BLUE_CONTAINER} + +envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/infra/nginx/templates/http-server.template > /home/ubuntu/moabam/infra/nginx/conf.d/http-server.conf +envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/infra/nginx/templates/ssl-server.template > /home/ubuntu/moabam/infra/nginx/conf.d/ssl-server.conf +envsubst '$BLUE_CONTAINER $SERVER_PORT' < /home/ubuntu/moabam/infra/nginx/templates/upstream.template > /home/ubuntu/moabam/infra/nginx/conf.d/upstream.conf diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc new file mode 100644 index 00000000..222be81b --- /dev/null +++ b/src/docs/asciidoc/coupon.adoc @@ -0,0 +1,112 @@ +== 쿠폰(Coupon) + + 쿠폰에 대해 생성/삭제/조회/발급/사용 기능을 제공합니다. + +--- + +=== 쿠폰 생성 + + 관리자가 쿠폰을 생성합니다. + +[discrete] +==== 요청 + +include::{snippets}/admins/coupons/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/admins/coupons/http-response.adoc[] + +--- + +=== 쿠폰 삭제 + + 관리자가 쿠폰 ID와 일치하는 쿠폰을 삭제합니다. + +[discrete] +==== 요청 + +include::{snippets}/admins/coupons/couponId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/admins/coupons/couponId/http-response.adoc[] + +--- + +=== 특정 쿠폰 조회 + + 관리자 혹은 사용자가 특정 ID와 일치하는 쿠폰을 조회합니다. + +==== 요청 + +include::{snippets}/coupons/couponId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/coupons/couponId/http-response.adoc[] + +--- + +=== 상태에 따른 쿠폰들을 조회 + + 관리자 혹은 사용자가 날짜 상태에 따라 쿠폰들을 조회합니다. + +==== 요청 + +include::{snippets}/coupons/search/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/coupons/search/http-response.adoc[] + +--- + +=== 특정 쿠폰에 대해 발급 + + 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다. + +==== 요청 + +include::{snippets}/coupons/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/coupons/http-response.adoc[] + +--- + +=== 특정 사용자의 쿠폰 보관함을 조회 + + 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. + +==== 요청 + +include::{snippets}/my-coupons/couponId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/my-coupons/couponId/http-response.adoc[] + +--- + +=== 쿠폰을 사용 + + 사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다. + +==== 요청 + +include::{snippets}/my-coupons/couponWalletId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/my-coupons/couponWalletId/http-response.adoc[] + +--- diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..ea559aeb --- /dev/null +++ b/src/docs/asciidoc/index.adoc @@ -0,0 +1,76 @@ += MOABAM API 문서 +:doctype: book +:icons: font +:source-highlighter: highlightjs +:toc: left +:toc-title: 목차 +:toclevels: 3 +:sectlinks: +:sectnums: + +== 개요 + +이 API 문서는 'MOABAM' 프로젝트의 산출물입니다. + +=== API 서버 경로 + +[cols="2,5,3"] +|==== +|환경 |DNS |비고 +|개발(dev) | link:[dev.moabam.com] | +|운영(prod) | link:[www.moabam.com] | +|==== + +[NOTE] +==== +해당 프로젝트 API 문서는 [특이사항]입니다. +==== + +[CAUTION] +==== +해당 프로젝트 API 문서는 [주의사항]입니다. +==== + +=== 응답형식 + +프로젝트는 다음과 같은 응답형식을 제공합니다. + +==== 정상(2XX) + +|==== +|응답데이터가 없는 경우|응답데이터가 있는 경우 + +a| +[source,json] +---- +{ + +} +---- + +a| +[source,json] +---- +{ + "name": "Hong-Dosan" +} +---- +|==== + +==== 상태코드(HttpStatus) + +응답시 다음과 같은 응답상태 헤더, 응답코드 및 응답메시지를 제공합니다. + +[cols="5,5"] +|==== +|HttpStatus |설명 + +|`OK(200)` |정상 응답 +|`CREATED(201)` |새로운 리소스 생성 +|`BAD_REQUEST(400)`|요청값 누락, 잘못된 기입 +|`UNAUTHORIZED(401)`|비인증 요청 +|`NOT_FOUND(404)`|요청값 누락, 잘못된 기입, 비인가 접속 등 +|`CONFLICT(409)`|요청값 중복 +|`INTERNAL_SERVER_ERROR(500)`|알 수 없는 서버 에러가 발생했습니다. 관리자에게 문의하세요. + +|==== diff --git a/src/docs/asciidoc/notification.adoc b/src/docs/asciidoc/notification.adoc new file mode 100644 index 00000000..2a2af666 --- /dev/null +++ b/src/docs/asciidoc/notification.adoc @@ -0,0 +1,34 @@ +== 알림(Notification) + + 콕 찌르기 알림, FCM Token 저장 기능을 제공합니다. + +=== 콕 찌르기 알림 + + 1) 특정 방의 사용자가 다른 사용자를 콕 찌릅니다. + 2) 서버에서 콕 찌를 대상의 FCM Token 여부를 검증합니다. + 3) Firebase 서버에 FCM Push Messaing 알림을 비동기로 요청합니다. + 4) Firebase 서버에서 FCM Token으로 식별된 기기에 알림을 보냅니다. + +[discrete] +==== 요청 + +include::{snippets}/notifications/rooms/roomId/members/memberId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/notifications/rooms/roomId/members/memberId/http-response.adoc[] + +=== FCM TOKEN 저장 + + 1) 특정 사용자의 FCM-TOKEN을 받아서 REDIS DB에 저장합니다. + +[discrete] +==== 요청 + +include::{snippets}/notifications/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/notifications/http-response.adoc[] diff --git a/src/main/java/com/moabam/MoabamServerApplication.java b/src/main/java/com/moabam/MoabamServerApplication.java index 5390acc3..e2dbce1d 100644 --- a/src/main/java/com/moabam/MoabamServerApplication.java +++ b/src/main/java/com/moabam/MoabamServerApplication.java @@ -2,12 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +@ConfigurationPropertiesScan @SpringBootApplication public class MoabamServerApplication { public static void main(String[] args) { SpringApplication.run(MoabamServerApplication.class, args); } - } diff --git a/src/main/java/com/moabam/admin/application/admin/AdminMapper.java b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java new file mode 100644 index 00000000..648c2e4d --- /dev/null +++ b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java @@ -0,0 +1,16 @@ +package com.moabam.admin.application.admin; + +import com.moabam.admin.domain.admin.Admin; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class AdminMapper { + + public static Admin toAdmin(Long socialId) { + return Admin.builder() + .socialId(String.valueOf(socialId)) + .build(); + } +} diff --git a/src/main/java/com/moabam/admin/application/admin/AdminService.java b/src/main/java/com/moabam/admin/application/admin/AdminService.java new file mode 100644 index 00000000..01243bd7 --- /dev/null +++ b/src/main/java/com/moabam/admin/application/admin/AdminService.java @@ -0,0 +1,57 @@ +package com.moabam.admin.application.admin; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.admin.domain.admin.Admin; +import com.moabam.admin.domain.admin.AdminRepository; +import com.moabam.api.application.auth.mapper.AuthMapper; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminService { + + @Value("${admin}") + private String adminLoginKey; + + private final AdminRepository adminRepository; + + public void validate(String state) { + if (!adminLoginKey.equals(state)) { + throw new BadRequestException(ErrorMessage.LOGIN_FAILED_ADMIN_KEY); + } + } + + @Transactional + public LoginResponse signUpOrLogin(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + return login(authorizationTokenInfoResponse); + } + + private LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + Optional admin = adminRepository.findBySocialId(String.valueOf(authorizationTokenInfoResponse.id())); + Admin loginMember = admin.orElseGet(() -> signUp(authorizationTokenInfoResponse.id())); + + return AuthMapper.toLoginResponse(loginMember, admin.isEmpty()); + } + + private Admin signUp(Long socialId) { + Admin admin = AdminMapper.toAdmin(socialId); + + return adminRepository.save(admin); + } + + public Admin findMember(Long id) { + return adminRepository.findById(id).orElseThrow(() -> new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moabam/admin/domain/admin/Admin.java b/src/main/java/com/moabam/admin/domain/admin/Admin.java new file mode 100644 index 00000000..eeff1bc3 --- /dev/null +++ b/src/main/java/com/moabam/admin/domain/admin/Admin.java @@ -0,0 +1,53 @@ +package com.moabam.admin.domain.admin; + +import static com.moabam.global.common.util.RandomUtils.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.common.entity.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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Admin extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "nickname", unique = true) + private String nickname; + + @Column(name = "social_id", nullable = false, unique = true) + private String socialId; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + @ColumnDefault("'ADMIN'") + private Role role; + + @Builder + private Admin(String socialId) { + this.socialId = requireNonNull(socialId); + this.nickname = createNickName(); + this.role = Role.ADMIN; + } + + private String createNickName() { + return "오목눈이#" + randomStringValues(); + } +} diff --git a/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java b/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java new file mode 100644 index 00000000..e4878786 --- /dev/null +++ b/src/main/java/com/moabam/admin/domain/admin/AdminRepository.java @@ -0,0 +1,10 @@ +package com.moabam.admin.domain.admin; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AdminRepository extends JpaRepository { + + Optional findBySocialId(String socialId); +} diff --git a/src/main/java/com/moabam/admin/presentation/admin/AdminController.java b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java new file mode 100644 index 00000000..cea5bb99 --- /dev/null +++ b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java @@ -0,0 +1,41 @@ +package com.moabam.admin.presentation.admin; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.admin.application.admin.AdminService; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.api.dto.auth.LoginResponse; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/admins") +@RequiredArgsConstructor +public class AdminController { + + private final AuthorizationService authorizationService; + private final AdminService adminService; + + @PostMapping("/login/kakao/oauth") + @ResponseStatus(HttpStatus.OK) + public LoginResponse authorizationTokenIssue(@RequestBody AuthorizationCodeResponse authorizationCodeResponse, + HttpServletResponse httpServletResponse) { + adminService.validate(authorizationCodeResponse.state()); + AuthorizationTokenResponse tokenResponse = authorizationService.requestAdminToken(authorizationCodeResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + authorizationService.requestTokenInfo(tokenResponse); + LoginResponse loginResponse = adminService.signUpOrLogin(authorizationTokenInfoResponse); + authorizationService.issueServiceToken(httpServletResponse, loginResponse.publicClaim()); + + return loginResponse; + } +} diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java new file mode 100644 index 00000000..ed2d0840 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -0,0 +1,239 @@ +package com.moabam.api.application.auth; + +import java.util.Arrays; + +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.util.UriComponentsBuilder; + +import com.moabam.admin.application.admin.AdminService; +import com.moabam.api.application.auth.mapper.AuthMapper; +import com.moabam.api.application.auth.mapper.AuthorizationMapper; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.auth.repository.TokenRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.auth.AuthorizationCodeRequest; +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenRequest; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.common.util.CookieUtils; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.AllowOriginConfig; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AuthorizationService { + + private final FcmService fcmService; + private final OAuthConfig oAuthConfig; + private final TokenConfig tokenConfig; + private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; + private final MemberService memberService; + private final AdminService adminService; + private final JwtProviderService jwtProviderService; + private final TokenRepository tokenRepository; + private final AllowOriginConfig allowOriginsConfig; + + public void redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizationCodeUri(); + oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); + } + + public AuthorizationTokenResponse requestAdminToken(AuthorizationCodeResponse authorizationCodeResponse) { + validAuthorizationGrant(authorizationCodeResponse.code()); + + return issueTokenToAuthorizationServer(authorizationCodeResponse.code(), + oAuthConfig.provider().adminRedirectUri()); + } + + public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) { + validAuthorizationGrant(authorizationCodeResponse.code()); + + return issueTokenToAuthorizationServer(authorizationCodeResponse.code(), oAuthConfig.provider().redirectUri()); + } + + public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { + String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); + ResponseEntity authorizationTokenInfoResponse = + oauth2AuthorizationServerRequestService + .tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + + return authorizationTokenInfoResponse.getBody(); + } + + public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, + AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + LoginResponse loginResponse = memberService.login(authorizationTokenInfoResponse); + issueServiceToken(httpServletResponse, loginResponse.publicClaim()); + + return loginResponse; + } + + public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) { + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + String refreshToken = jwtProviderService.provideRefreshToken(publicClaim.role()); + TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null); + + tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest, publicClaim.role()); + + String domain = getDomain(publicClaim.role()); + + response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain)); + response.addCookie(CookieUtils + .tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain)); + response.addCookie(CookieUtils + .tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire(), domain)); + } + + public void validTokenPair(Long id, String oldRefreshToken, Role role) { + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id, role); + + if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) { + tokenRepository.delete(id, role); + + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + } + + public void logout(AuthMember authMember, + HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + removeToken(httpServletRequest, httpServletResponse); + tokenRepository.delete(authMember.id(), authMember.role()); + fcmService.deleteTokenByMemberId(authMember.id()); + } + + public void removeToken(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + if (httpServletRequest.getCookies() == null) { + return; + } + + Arrays.stream(httpServletRequest.getCookies()).forEach(cookie -> { + if (cookie.getName().contains("token")) { + httpServletResponse.addCookie(CookieUtils.deleteCookie(cookie)); + } + }); + } + + @Transactional + public void unLinkMember(AuthMember authMember) { + memberService.validateMemberToDelete(authMember.id()); + Member member = memberService.findMember(authMember.id()); + unlinkRequest(member.getSocialId()); + memberService.delete(member); + } + + private String getDomain(Role role) { + if (role.equals(Role.ADMIN)) { + return allowOriginsConfig.adminDomain(); + } + + return allowOriginsConfig.domain(); + } + + private void unlinkRequest(String socialId) { + try { + oauth2AuthorizationServerRequestService.unlinkMemberRequest(oAuthConfig.provider().unlink(), + oAuthConfig.client().adminKey(), unlinkRequestParam(socialId)); + log.info("회원 탈퇴 성공 : [socialId={}]", socialId); + } catch (BadRequestException badRequestException) { + log.warn("회원 탈퇴요청 실패 : 카카오 연결 오류"); + throw new BadRequestException(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS); + } + } + + private MultiValueMap unlinkRequestParam(String socialId) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", socialId); + + return params; + } + + private String getAuthorizationCodeUri() { + AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oAuthConfig); + return generateQueryParamsWith(authorizationCodeRequest); + } + + private String generateTokenValue(String token) { + return "Bearer" + GlobalConstant.SPACE + token; + } + + private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) { + UriComponentsBuilder authorizationCodeUri = + UriComponentsBuilder.fromUriString( + oAuthConfig.provider() + .authorizationUri()) + .queryParam("response_type", "code") + .queryParam("client_id", authorizationCodeRequest.clientId()) + .queryParam("redirect_uri", authorizationCodeRequest.redirectUri()); + + if (authorizationCodeRequest.scope() != null && !authorizationCodeRequest.scope().isEmpty()) { + String scopes = String.join(",", authorizationCodeRequest.scope()); + authorizationCodeUri.queryParam("scope", scopes); + } + + return authorizationCodeUri.toUriString(); + } + + private void validAuthorizationGrant(String code) { + if (code == null) { + throw new BadRequestException(ErrorMessage.GRANT_FAILED); + } + } + + private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code, String redirectUri) { + AuthorizationTokenRequest authorizationTokenRequest = + AuthorizationMapper.toAuthorizationTokenRequest(oAuthConfig, code, redirectUri); + MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest); + ResponseEntity authorizationTokenResponse = + oauth2AuthorizationServerRequestService + .requestAuthorizationServer(oAuthConfig.provider().tokenUri(), uriParams); + + return authorizationTokenResponse.getBody(); + } + + private MultiValueMap generateTokenRequest(AuthorizationTokenRequest authorizationTokenRequest) { + MultiValueMap contents = new LinkedMultiValueMap<>(); + contents.add("grant_type", authorizationTokenRequest.grantType()); + contents.add("client_id", authorizationTokenRequest.clientId()); + contents.add("redirect_uri", authorizationTokenRequest.redirectUri()); + contents.add("code", authorizationTokenRequest.code()); + + if (authorizationTokenRequest.clientSecret() != null) { + contents.add("client_secret", authorizationTokenRequest.clientSecret()); + } + + return contents; + } + + public void validMemberExist(Long id, Role role) { + if (role.equals(Role.ADMIN)) { + adminService.findMember(id); + + return; + } + + memberService.findMember(id); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java new file mode 100644 index 00000000..52c652f9 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java @@ -0,0 +1,59 @@ +package com.moabam.api.application.auth; + +import java.nio.charset.StandardCharsets; +import java.security.Key; + +import org.json.JSONObject; +import org.springframework.stereotype.Service; + +import com.moabam.api.application.auth.mapper.AuthorizationMapper; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtAuthenticationService { + + private final TokenConfig tokenConfig; + + public boolean isTokenExpire(String token, Role role) { + try { + Key key = getSecret(role); + + Jwts.parserBuilder() + .setSigningKey(key) + .build() + .parseClaimsJws(token); + return false; + } catch (ExpiredJwtException expiredJwtException) { + return true; + } catch (Exception exception) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + } + + private Key getSecret(Role role) { + if (role.equals(Role.ADMIN)) { + return tokenConfig.getAdminKey(); + } + + return tokenConfig.getKey(); + } + + public PublicClaim parseClaim(String token) { + String claims = token.split("\\.")[1]; + byte[] claimsBytes = Decoders.BASE64URL.decode(claims); + String decodedClaims = new String(claimsBytes, StandardCharsets.UTF_8); + JSONObject jsonObject = new JSONObject(decodedClaims); + + return AuthorizationMapper.toPublicClaim(jsonObject); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/JwtProviderService.java b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java new file mode 100644 index 00000000..816985f2 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java @@ -0,0 +1,63 @@ +package com.moabam.api.application.auth; + +import java.security.Key; +import java.util.Date; + +import org.springframework.stereotype.Service; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.config.TokenConfig; + +import io.jsonwebtoken.JwtBuilder; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtProviderService { + + private final TokenConfig tokenConfig; + + public String provideAccessToken(PublicClaim publicClaim) { + return generateIdToken(publicClaim, tokenConfig.getAccessExpire()); + } + + public String provideRefreshToken(Role role) { + return generateCommonInfo(tokenConfig.getRefreshExpire(), role); + } + + private String generateIdToken(PublicClaim publicClaim, long expireTime) { + return commonInfo(expireTime, publicClaim.role()) + .claim("id", publicClaim.id()) + .claim("nickname", publicClaim.nickname()) + .claim("role", publicClaim.role()) + .compact(); + } + + private String generateCommonInfo(long expireTime, Role role) { + return commonInfo(expireTime, role).compact(); + } + + private JwtBuilder commonInfo(long expireTime, Role role) { + Date issueDate = new Date(); + Date expireDate = new Date(issueDate.getTime() + expireTime); + + return Jwts.builder() + .setHeaderParam("alg", "HS256") + .setHeaderParam("typ", "JWT") + .setIssuer(tokenConfig.getIss()) + .setIssuedAt(issueDate) + .setExpiration(expireDate) + .signWith(getSecretKey(role), SignatureAlgorithm.HS256); + } + + private Key getSecretKey(Role role) { + if (role.equals(Role.ADMIN)) { + return tokenConfig.getAdminKey(); + } + + return tokenConfig.getKey(); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java new file mode 100644 index 00000000..44db540c --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java @@ -0,0 +1,71 @@ +package com.moabam.api.application.auth; + +import java.io.IOException; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.handler.RestTemplateResponseHandler; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; + +@Service +public class OAuth2AuthorizationServerRequestService { + + private final RestTemplate restTemplate; + + public OAuth2AuthorizationServerRequestService() { + restTemplate = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()) + .build(); + } + + public void loginRequest(HttpServletResponse httpServletResponse, String authorizationCodeUri) { + try { + httpServletResponse.setContentType(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + httpServletResponse.sendRedirect(authorizationCodeUri); + } catch (IOException e) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + public ResponseEntity requestAuthorizationServer(String tokenUri, + MultiValueMap uriParams) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8); + HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); + + return restTemplate.exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + } + + public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) { + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", tokenValue); + HttpEntity httpEntity = new HttpEntity<>(headers); + + return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); + } + + public void unlinkMemberRequest(String unlinkUri, String adminKey, MultiValueMap params) { + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8); + headers.add("Authorization", "KakaoAK " + adminKey); + HttpEntity> httpEntity = new HttpEntity<>(params, headers); + + restTemplate.exchange(unlinkUri, HttpMethod.POST, httpEntity, Void.class); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java new file mode 100644 index 00000000..0fe48a56 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java @@ -0,0 +1,43 @@ +package com.moabam.api.application.auth.mapper; + +import com.moabam.admin.domain.admin.Admin; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.global.auth.model.PublicClaim; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthMapper { + + public static LoginResponse toLoginResponse(Member member, boolean isSignUp) { + return LoginResponse.builder() + .publicClaim(PublicClaim.builder() + .id(member.getId()) + .nickname(member.getNickname()) + .role(member.getRole()) + .build()) + .isSignUp(isSignUp) + .build(); + } + + public static LoginResponse toLoginResponse(Admin admin, boolean isSignUp) { + return LoginResponse.builder() + .publicClaim(PublicClaim.builder() + .id(admin.getId()) + .nickname(admin.getNickname()) + .role(admin.getRole()) + .build()) + .isSignUp(isSignUp) + .build(); + } + + public static TokenSaveValue toTokenSaveValue(String refreshToken, String ip) { + return TokenSaveValue.builder() + .refreshToken(refreshToken) + .loginIp(ip) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java new file mode 100644 index 00000000..3e566153 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java @@ -0,0 +1,48 @@ +package com.moabam.api.application.auth.mapper; + +import org.json.JSONObject; + +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.auth.AuthorizationCodeRequest; +import com.moabam.api.dto.auth.AuthorizationTokenRequest; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.config.OAuthConfig; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthorizationMapper { + + public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) { + return AuthorizationCodeRequest.builder() + .clientId(oAuthConfig.client().clientId()) + .redirectUri(oAuthConfig.provider().redirectUri()) + .scope(oAuthConfig.client().scope()) + .build(); + } + + public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig oAuthConfig, String code, + String redirectUri) { + return AuthorizationTokenRequest.builder() + .grantType(oAuthConfig.client().authorizationGrantType()) + .clientId(oAuthConfig.client().clientId()) + .redirectUri(redirectUri) + .code(code) + .clientSecret(oAuthConfig.client().clientSecret()) + .build(); + } + + public static PublicClaim toPublicClaim(JSONObject jsonObject) { + return PublicClaim.builder() + .id(Long.valueOf(jsonObject.get("id").toString())) + .nickname(jsonObject.getString("nickname")) + .role(jsonObject.getEnum(Role.class, "role")) + .build(); + } + + public static AuthMember toAuthMember(PublicClaim publicClaim) { + return new AuthMember(publicClaim.id(), publicClaim.nickname(), publicClaim.role()); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java new file mode 100644 index 00000000..4ef9db71 --- /dev/null +++ b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java @@ -0,0 +1,43 @@ +package com.moabam.api.application.auth.mapper; + +import static java.util.Objects.*; + +import java.util.List; + +import org.springframework.http.HttpMethod; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.handler.PathResolver; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class PathMapper { + + public static PathResolver.Path parsePath(String uri) { + return parsePath(uri, null, null); + } + + public static PathResolver.Path pathWithRole(String uri, List params) { + return parsePath(uri, params, null); + } + + public static PathResolver.Path pathWithMethod(String uri, List params) { + return parsePath(uri, null, params); + } + + private static PathResolver.Path parsePath(String uri, List roles, List methods) { + PathResolver.Path.PathBuilder pathBuilder = PathResolver.Path.builder().uri(uri); + + if (nonNull(roles)) { + pathBuilder.roles(roles); + } + + if (nonNull(methods)) { + pathBuilder.httpMethods(methods); + } + + return pathBuilder.build(); + } +} diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java new file mode 100644 index 00000000..af91b206 --- /dev/null +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -0,0 +1,83 @@ +package com.moabam.api.application.bug; + +import java.util.List; + +import com.moabam.api.application.payment.PaymentMapper; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugHistory; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.dto.bug.BugHistoryItemResponse; +import com.moabam.api.dto.bug.BugHistoryResponse; +import com.moabam.api.dto.bug.BugHistoryWithPayment; +import com.moabam.api.dto.bug.BugResponse; +import com.moabam.global.common.util.DateUtils; +import com.moabam.global.common.util.StreamUtils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class BugMapper { + + public static BugResponse toBugResponse(Bug bug) { + return BugResponse.builder() + .morningBug(bug.getMorningBug()) + .nightBug(bug.getNightBug()) + .goldenBug(bug.getGoldenBug()) + .build(); + } + + public static BugHistoryItemResponse toBugHistoryItemResponse(BugHistoryWithPayment dto) { + return BugHistoryItemResponse.builder() + .id(dto.id()) + .bugType(dto.bugType()) + .actionType(dto.actionType()) + .quantity(dto.quantity()) + .date(DateUtils.format(dto.createdAt())) + .payment(PaymentMapper.toPaymentResponse(dto.payment())) + .build(); + } + + public static BugHistoryResponse toBugHistoryResponse(List dtoList) { + return BugHistoryResponse.builder() + .history(StreamUtils.map(dtoList, BugMapper::toBugHistoryItemResponse)) + .build(); + } + + public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(bugType) + .actionType(BugActionType.USE) + .quantity(quantity) + .build(); + } + + public static BugHistory toChargeBugHistory(Long memberId, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(BugType.GOLDEN) + .actionType(BugActionType.CHARGE) + .quantity(quantity) + .build(); + } + + public static BugHistory toRewardBugHistory(Long memberId, BugType bugType, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(bugType) + .actionType(BugActionType.REWARD) + .quantity(quantity) + .build(); + } + + public static BugHistory toCouponBugHistory(Long memberId, BugType bugType, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(bugType) + .actionType(BugActionType.COUPON) + .quantity(quantity) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java new file mode 100644 index 00000000..f1246d45 --- /dev/null +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -0,0 +1,139 @@ +package com.moabam.api.application.bug; + +import static com.moabam.api.domain.product.ProductType.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.payment.PaymentMapper; +import com.moabam.api.application.product.ProductMapper; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.bug.BugHistoryResponse; +import com.moabam.api.dto.bug.BugHistoryWithPayment; +import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; +import com.moabam.api.dto.product.PurchaseProductRequest; +import com.moabam.api.dto.product.PurchaseProductResponse; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BugService { + + private final MemberService memberService; + private final BugHistoryRepository bugHistoryRepository; + private final BugHistorySearchRepository bugHistorySearchRepository; + private final ProductRepository productRepository; + private final PaymentRepository paymentRepository; + private final CouponWalletSearchRepository couponWalletSearchRepository; + + public BugResponse getBug(Long memberId) { + Bug bug = getByMemberId(memberId); + + return BugMapper.toBugResponse(bug); + } + + public BugHistoryResponse getBugHistory(Long memberId) { + List history = bugHistorySearchRepository.findByMemberIdWithPayment(memberId); + + return BugMapper.toBugHistoryResponse(history); + } + + public ProductsResponse getBugProducts() { + List products = productRepository.findAllByType(BUG); + + return ProductMapper.toProductsResponse(products); + } + + @Transactional + public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, PurchaseProductRequest request) { + Product product = getProductById(productId); + Payment payment = PaymentMapper.toPayment(memberId, product); + + if (!isNull(request.couponWalletId())) { + CouponWallet couponWallet = getCouponWallet(request.couponWalletId(), memberId); + payment.applyCoupon(couponWallet); + } + paymentRepository.save(payment); + + return ProductMapper.toPurchaseProductResponse(payment); + } + + @Transactional + public void use(Member member, BugType bugType, int count) { + if (count == 0) { + return; + } + + Bug bug = member.getBug(); + + bug.use(bugType, count); + bugHistoryRepository.save(BugMapper.toUseBugHistory(member.getId(), bugType, count)); + } + + @Transactional + public void reward(Member member, BugType bugType, int count) { + if (count == 0) { + return; + } + + Bug bug = member.getBug(); + + bug.increase(bugType, count); + bugHistoryRepository.save(BugMapper.toRewardBugHistory(member.getId(), bugType, count)); + } + + @Transactional + public void charge(Long memberId, Product bugProduct) { + Bug bug = getByMemberId(memberId); + + bug.charge(bugProduct.getQuantity()); + bugHistoryRepository.save(BugMapper.toChargeBugHistory(memberId, bugProduct.getQuantity())); + } + + @Transactional + public void applyCoupon(Long memberId, BugType bugType, int count) { + if (count == 0) { + return; + } + + Bug bug = getByMemberId(memberId); + + bug.increase(bugType, count); + bugHistoryRepository.save(BugMapper.toCouponBugHistory(memberId, bugType, count)); + } + + private Bug getByMemberId(Long memberId) { + return memberService.findMember(memberId) + .getBug(); + } + + private Product getProductById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException(PRODUCT_NOT_FOUND)); + } + + private CouponWallet getCouponWallet(Long couponWalletId, Long memberId) { + return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); + } +} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java new file mode 100644 index 00000000..3cda6f9a --- /dev/null +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -0,0 +1,101 @@ +package com.moabam.api.application.coupon; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import com.moabam.api.application.notification.NotificationService; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponManageRepository; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CouponManageService { + + private static final String SUCCESS_ISSUE_BODY = "%s 쿠폰 발행을 성공했습니다. 축하드립니다!"; + private static final String FAIL_ISSUE_BODY = "%s 쿠폰 발행을 실패했습니다. 다음 기회에!"; + private static final long ISSUE_SIZE = 10; + + private final ClockHolder clockHolder; + private final NotificationService notificationService; + + private final CouponRepository couponRepository; + private final CouponManageRepository couponManageRepository; + private final CouponWalletRepository couponWalletRepository; + + @Scheduled(fixedDelay = 1000) + public void issue() { + LocalDate now = clockHolder.date(); + Optional optionalCoupon = couponRepository.findByStartAt(now); + + if (optionalCoupon.isEmpty()) { + return; + } + + Coupon coupon = optionalCoupon.get(); + String couponName = coupon.getName(); + int maxCount = coupon.getMaxCount(); + int currentCount = couponManageRepository.getCount(couponName); + + if (maxCount <= currentCount) { + return; + } + + Set membersId = couponManageRepository.rangeQueue(couponName, currentCount, currentCount + ISSUE_SIZE); + + if (membersId.isEmpty()) { + return; + } + + for (Long memberId : membersId) { + couponWalletRepository.save(CouponWallet.create(memberId, coupon)); + notificationService.sendCouponIssueResult(memberId, couponName, SUCCESS_ISSUE_BODY); + } + + couponManageRepository.increase(couponName, membersId.size()); + } + + public void registerQueue(String couponName, Long memberId) { + double registerTime = System.currentTimeMillis(); + validateRegisterQueue(couponName, memberId); + couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime); + } + + public void delete(String couponName) { + couponManageRepository.deleteQueue(couponName); + couponManageRepository.deleteCount(couponName); + } + + private void validateRegisterQueue(String couponName, Long memberId) { + LocalDate now = clockHolder.date(); + Coupon coupon = couponRepository.findByNameAndStartAt(couponName, now) + .orElseThrow(() -> new NotFoundException(ErrorMessage.INVALID_COUPON_PERIOD)); + + if (couponManageRepository.hasValue(couponName, memberId)) { + throw new ConflictException(ErrorMessage.CONFLICT_COUPON_ISSUE); + } + + int maxCount = coupon.getMaxCount(); + int sizeQueue = couponManageRepository.sizeQueue(couponName); + + if (maxCount <= sizeQueue) { + notificationService.sendCouponIssueResult(memberId, couponName, FAIL_ISSUE_BODY); + throw new BadRequestException(ErrorMessage.INVALID_COUPON_STOCK_END); + } + } +} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java new file mode 100644 index 00000000..a3cbe1c3 --- /dev/null +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -0,0 +1,67 @@ +package com.moabam.api.application.coupon; + +import java.util.List; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.dto.coupon.CouponResponse; +import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.api.dto.coupon.MyCouponResponse; +import com.moabam.global.common.util.StreamUtils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CouponMapper { + + public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { + return Coupon.builder() + .name(coupon.name()) + .description(coupon.description()) + .type(CouponType.from(coupon.type())) + .point(coupon.point()) + .maxCount(coupon.maxCount()) + .startAt(coupon.startAt()) + .openAt(coupon.openAt()) + .adminId(adminId) + .build(); + } + + // TODO : Admin Table 생성 시, 관리자 명 추가할 예정 + public static CouponResponse toResponse(Coupon coupon) { + return CouponResponse.builder() + .id(coupon.getId()) + .adminId(coupon.getAdminId()) + .name(coupon.getName()) + .description(coupon.getDescription()) + .point(coupon.getPoint()) + .maxCount(coupon.getMaxCount()) + .type(coupon.getType()) + .startAt(coupon.getStartAt()) + .openAt(coupon.getOpenAt()) + .build(); + } + + public static List toResponses(List coupons) { + return StreamUtils.map(coupons, CouponMapper::toResponse); + } + + public static MyCouponResponse toMyResponse(CouponWallet couponWallet) { + Coupon coupon = couponWallet.getCoupon(); + + return MyCouponResponse.builder() + .walletId(couponWallet.getId()) + .id(coupon.getId()) + .name(coupon.getName()) + .description(coupon.getDescription()) + .point(coupon.getPoint()) + .type(coupon.getType()) + .build(); + } + + public static List toMyResponses(List couponWallets) { + return StreamUtils.map(couponWallets, CouponMapper::toMyResponse); + } +} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java new file mode 100644 index 00000000..00ec0fa3 --- /dev/null +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -0,0 +1,147 @@ +package com.moabam.api.application.coupon; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.admin.application.admin.AdminService; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.coupon.CouponResponse; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.api.dto.coupon.MyCouponResponse; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponService { + + private final ClockHolder clockHolder; + private final BugService bugService; + private final AdminService adminService; + private final CouponManageService couponManageService; + + private final CouponRepository couponRepository; + private final CouponSearchRepository couponSearchRepository; + private final CouponWalletRepository couponWalletRepository; + private final CouponWalletSearchRepository couponWalletSearchRepository; + + @Transactional + public void create(CreateCouponRequest request, Long adminId, Role role) { + validateAdminRole(role); + validateConflictName(request.name()); + validateConflictStartAt(request.startAt()); + validatePeriod(request.startAt(), request.openAt()); + + Coupon coupon = CouponMapper.toEntity(adminId, request); + + couponRepository.save(coupon); + } + + @Transactional + public void delete(Long couponId, Role role) { + validateAdminRole(role); + + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + + couponRepository.delete(coupon); + couponManageService.delete(coupon.getName()); + } + + @Transactional + public void use(Long couponWalletId, Long memberId) { + CouponWallet couponWallet = couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); + Coupon coupon = couponWallet.getCoupon(); + BugType bugType = coupon.getType().getBugType(); + + if (coupon.getType().isDiscount()) { + throw new BadRequestException(ErrorMessage.INVALID_DISCOUNT_COUPON); + } + + bugService.applyCoupon(memberId, bugType, coupon.getPoint()); + couponWalletRepository.delete(couponWallet); + } + + @Transactional + public void discount(Long couponWalletId, Long memberId) { + CouponWallet couponWallet = couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); + Coupon coupon = couponWallet.getCoupon(); + + if (!coupon.getType().isDiscount()) { + throw new BadRequestException(ErrorMessage.INVALID_BUG_COUPON); + } + + couponWalletRepository.delete(couponWallet); + } + + public CouponResponse getById(Long couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + + return CouponMapper.toResponse(coupon); + } + + public List getAllByStatus(CouponStatusRequest request) { + LocalDate now = clockHolder.date(); + List coupons = couponSearchRepository.findAllByStatus(now, request); + + return CouponMapper.toResponses(coupons); + } + + public List getAllByWalletIdAndMemberId(Long couponWalletId, Long memberId) { + List couponWallets = + couponWalletSearchRepository.findAllByIdAndMemberId(couponWalletId, memberId); + + return CouponMapper.toMyResponses(couponWallets); + } + + private void validatePeriod(LocalDate startAt, LocalDate openAt) { + LocalDate now = clockHolder.date(); + + if (!now.isBefore(startAt)) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_START_AT_PERIOD); + } + + if (!openAt.isBefore(startAt)) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD); + } + } + + private void validateAdminRole(Role role) { + if (!role.equals(Role.ADMIN)) { + throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND); + } + } + + private void validateConflictName(String couponName) { + if (couponRepository.existsByName(couponName)) { + throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); + } + } + + private void validateConflictStartAt(LocalDate startAt) { + if (couponRepository.existsByStartAt(startAt)) { + throw new ConflictException(ErrorMessage.CONFLICT_COUPON_START_AT); + } + } +} diff --git a/src/main/java/com/moabam/api/application/image/ImageService.java b/src/main/java/com/moabam/api/application/image/ImageService.java new file mode 100644 index 00000000..a70a783f --- /dev/null +++ b/src/main/java/com/moabam/api/application/image/ImageService.java @@ -0,0 +1,73 @@ +package com.moabam.api.application.image; + +import static com.moabam.global.error.model.ErrorMessage.IMAGE_CONVERT_FAIL; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.domain.image.ImageName; +import com.moabam.api.domain.image.ImageResizer; +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.domain.image.NewImage; +import com.moabam.api.dto.room.CertifyRoomsRequest; +import com.moabam.api.infrastructure.s3.S3Manager; +import com.moabam.global.error.exception.BadRequestException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ImageService { + + private final S3Manager s3Manager; + + @Transactional + public List uploadImages(List multipartFiles, ImageType imageType) { + + List result = new ArrayList<>(); + + List imageResizers = multipartFiles.stream() + .map(multipartFile -> this.toImageResizer(multipartFile, imageType)) + .toList(); + + imageResizers.forEach(resizer -> { + resizer.resizeImageToFixedSize(imageType); + result.add(s3Manager.uploadImage(resizer.getResizedImage().getName(), resizer.getResizedImage())); + }); + + return result; + } + + public List getNewImages(CertifyRoomsRequest request) { + return request.getCertifyRoomsRequest().stream() + .map(certifyRoomRequest -> { + try { + return NewImage.of(String.valueOf(certifyRoomRequest.getRoutineId()), + certifyRoomRequest.getImage().getContentType(), certifyRoomRequest.getImage().getBytes()); + } catch (IOException e) { + throw new BadRequestException(IMAGE_CONVERT_FAIL); + } + }) + .toList(); + } + + @Transactional + public void deleteImage(String imageUrl) { + s3Manager.deleteImage(imageUrl); + } + + private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType imageType) { + ImageName imageName = ImageName.of(multipartFile, imageType); + + return ImageResizer.builder() + .image(multipartFile) + .fileName(imageName.getFileName()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/item/ItemMapper.java b/src/main/java/com/moabam/api/application/item/ItemMapper.java new file mode 100644 index 00000000..f2cabfff --- /dev/null +++ b/src/main/java/com/moabam/api/application/item/ItemMapper.java @@ -0,0 +1,44 @@ +package com.moabam.api.application.item; + +import java.util.List; + +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.dto.item.ItemResponse; +import com.moabam.api.dto.item.ItemsResponse; +import com.moabam.global.common.util.StreamUtils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ItemMapper { + + public static ItemResponse toItemResponse(Item item) { + return ItemResponse.builder() + .id(item.getId()) + .type(item.getType().name()) + .category(item.getCategory().name()) + .name(item.getName()) + .image(item.getAwakeImage()) + .level(item.getUnlockLevel()) + .bugPrice(item.getBugPrice()) + .goldenBugPrice(item.getGoldenBugPrice()) + .build(); + } + + public static ItemsResponse toItemsResponse(Long itemId, List purchasedItems, List notPurchasedItems) { + return ItemsResponse.builder() + .defaultItemId(itemId) + .purchasedItems(StreamUtils.map(purchasedItems, ItemMapper::toItemResponse)) + .notPurchasedItems(StreamUtils.map(notPurchasedItems, ItemMapper::toItemResponse)) + .build(); + } + + public static Inventory toInventory(Long memberId, Item item) { + return Inventory.builder() + .memberId(memberId) + .item(item) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java new file mode 100644 index 00000000..b0f7f0f9 --- /dev/null +++ b/src/main/java/com/moabam/api/application/item/ItemService.java @@ -0,0 +1,92 @@ +package com.moabam.api.application.item; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.item.repository.ItemSearchRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.item.ItemsResponse; +import com.moabam.api.dto.item.PurchaseItemRequest; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ItemService { + + private final MemberService memberService; + private final BugService bugService; + private final ItemRepository itemRepository; + private final ItemSearchRepository itemSearchRepository; + private final InventoryRepository inventoryRepository; + private final InventorySearchRepository inventorySearchRepository; + + public ItemsResponse getItems(Long memberId, ItemType type) { + Item defaultItem = getDefaultInventory(memberId, type).getItem(); + List purchasedItems = inventorySearchRepository.findItems(memberId, type); + List notPurchasedItems = itemSearchRepository.findNotPurchasedItems(memberId, type); + + return ItemMapper.toItemsResponse(defaultItem.getId(), purchasedItems, notPurchasedItems); + } + + @Transactional + public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request) { + Item item = getItem(itemId); + Member member = memberService.findMember(memberId); + + validateAlreadyPurchased(memberId, itemId); + item.validatePurchasable(request.bugType(), member.getLevel()); + + int price = item.getPrice(request.bugType()); + + bugService.use(member, request.bugType(), price); + inventoryRepository.save(ItemMapper.toInventory(memberId, item)); + } + + @Transactional + public void selectItem(Long memberId, Long itemId) { + Member member = memberService.findMember(memberId); + Inventory inventory = getInventory(memberId, itemId); + + inventorySearchRepository.findDefault(memberId, inventory.getItemType()) + .ifPresent(Inventory::deselect); + inventory.select(member); + } + + private Item getItem(Long itemId) { + return itemRepository.findById(itemId) + .orElseThrow(() -> new NotFoundException(ITEM_NOT_FOUND)); + } + + private Inventory getInventory(Long memberId, Long itemId) { + return inventorySearchRepository.findOne(memberId, itemId) + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); + } + + private Inventory getDefaultInventory(Long memberId, ItemType type) { + return inventorySearchRepository.findDefault(memberId, type) + .orElseThrow(() -> new NotFoundException(DEFAULT_INVENTORY_NOT_FOUND)); + } + + private void validateAlreadyPurchased(Long memberId, Long itemId) { + inventorySearchRepository.findOne(memberId, itemId) + .ifPresent(inventory -> { + throw new ConflictException(INVENTORY_CONFLICT); + }); + } +} diff --git a/src/main/java/com/moabam/api/application/member/BadgeService.java b/src/main/java/com/moabam/api/application/member/BadgeService.java new file mode 100644 index 00000000..e89dc5dd --- /dev/null +++ b/src/main/java/com/moabam/api/application/member/BadgeService.java @@ -0,0 +1,32 @@ +package com.moabam.api.application.member; + +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.member.Badge; +import com.moabam.api.domain.member.BadgeType; +import com.moabam.api.domain.member.repository.BadgeRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class BadgeService { + + private final BadgeRepository badgeRepository; + + public void createBadge(Long memberId, long certifyCount) { + Optional badgeType = BadgeType.getBadgeFrom(certifyCount); + + if (badgeType.isEmpty() + || badgeRepository.existsByMemberIdAndType(memberId, badgeType.get())) { + return; + } + + Badge badge = MemberMapper.toBadge(memberId, badgeType.get()); + badgeRepository.save(badge); + } +} diff --git a/src/main/java/com/moabam/api/application/member/MemberMapper.java b/src/main/java/com/moabam/api/application/member/MemberMapper.java new file mode 100644 index 00000000..7d80c808 --- /dev/null +++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java @@ -0,0 +1,118 @@ +package com.moabam.api.application.member; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.member.Badge; +import com.moabam.api.domain.member.BadgeType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.member.BadgeResponse; +import com.moabam.api.dto.member.MemberInfo; +import com.moabam.api.dto.member.MemberInfoResponse; +import com.moabam.api.dto.member.MemberInfoSearchResponse; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.UpdateRanking; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MemberMapper { + + public static Member toMember(Long socialId) { + return Member.builder() + .socialId(String.valueOf(socialId)) + .bug(Bug.builder().build()) + .build(); + } + + public static UpdateRanking toUpdateRanking(Member member) { + return UpdateRanking.builder() + .rankingInfo(toRankingInfo(member)) + .score(member.getTotalCertifyCount()) + .build(); + } + + public static MemberInfoSearchResponse toMemberInfoSearchResponse(List memberInfos) { + MemberInfo infos = memberInfos.get(0); + List badgeTypes = memberInfos.stream() + .map(MemberInfo::badges) + .filter(Objects::nonNull) + .toList(); + + return MemberInfoSearchResponse.builder() + .nickname(infos.nickname()) + .profileImage(infos.profileImage()) + .morningImage(infos.morningImage()) + .nightImage(infos.nightImage()) + .intro(infos.intro()) + .totalCertifyCount(infos.totalCertifyCount()) + .badges(new HashSet<>(badgeTypes)) + .goldenBug(infos.goldenBug()) + .morningBug(infos.morningBug()) + .nightBug(infos.nightBug()) + .build(); + } + + public static MemberInfoResponse toMemberInfoResponse(MemberInfoSearchResponse memberInfoSearchResponse) { + long certifyCount = memberInfoSearchResponse.totalCertifyCount(); + + return MemberInfoResponse.builder() + .nickname(memberInfoSearchResponse.nickname()) + .profileImage(memberInfoSearchResponse.profileImage()) + .intro(memberInfoSearchResponse.intro()) + .level(certifyCount / LEVEL_DIVISOR) + .exp(certifyCount % LEVEL_DIVISOR) + .birds(defaultSkins(memberInfoSearchResponse.morningImage(), memberInfoSearchResponse.nightImage())) + .badges(badgedNames(memberInfoSearchResponse.badges())) + .goldenBug(memberInfoSearchResponse.goldenBug()) + .morningBug(memberInfoSearchResponse.morningBug()) + .nightBug(memberInfoSearchResponse.nightBug()) + .build(); + } + + public static Inventory toInventory(Long memberId, Item item) { + return Inventory.builder() + .memberId(memberId) + .item(item) + .isDefault(true) + .build(); + } + + public static RankingInfo toRankingInfo(Member member) { + return RankingInfo.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .image(member.getProfileImage()) + .build(); + } + + public static Badge toBadge(Long memberId, BadgeType badgeType) { + return Badge.builder() + .type(badgeType) + .memberId(memberId) + .build(); + } + + private static List badgedNames(Set badgeTypes) { + return BadgeType.memberBadgeMap(badgeTypes); + } + + private static Map defaultSkins(String morningImage, String nightImage) { + Map birdsSkin = new HashMap<>(); + birdsSkin.put(ItemType.MORNING.name(), morningImage); + birdsSkin.put(ItemType.NIGHT.name(), nightImage); + + return birdsSkin; + } +} diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java new file mode 100644 index 00000000..ef3a761c --- /dev/null +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -0,0 +1,206 @@ +package com.moabam.api.application.member; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.auth.mapper.AuthMapper; +import com.moabam.api.application.ranking.RankingService; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.member.repository.MemberSearchRepository; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.member.MemberInfo; +import com.moabam.api.dto.member.MemberInfoResponse; +import com.moabam.api.dto.member.MemberInfoSearchResponse; +import com.moabam.api.dto.member.ModifyMemberRequest; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.UpdateRanking; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.common.util.BaseDataCode; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; + +import io.micrometer.common.util.StringUtils; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberService { + + private final RankingService rankingService; + private final FcmService fcmService; + private final MemberRepository memberRepository; + private final InventoryRepository inventoryRepository; + private final ItemRepository itemRepository; + private final MemberSearchRepository memberSearchRepository; + private final ParticipantSearchRepository participantSearchRepository; + private final ParticipantRepository participantRepository; + private final ClockHolder clockHolder; + + public Member findMember(Long memberId) { + return memberSearchRepository.findMember(memberId) + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + } + + @Transactional + public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + Optional member = memberRepository.findBySocialId(String.valueOf(authorizationTokenInfoResponse.id())); + Member loginMember = member.orElseGet(() -> signUp(authorizationTokenInfoResponse.id())); + + return AuthMapper.toLoginResponse(loginMember, member.isEmpty()); + } + + public List getRoomMembers(List memberIds) { + return memberRepository.findAllById(memberIds); + } + + public void validateMemberToDelete(Long memberId) { + List participants = memberSearchRepository.findParticipantByMemberId(memberId); + + if (!participants.isEmpty()) { + throw new NotFoundException(MEMBER_NOT_FOUND); + } + } + + @Transactional + public void delete(Member member) { + List participants = participantSearchRepository.findAllByMemberIdParticipant(member.getId()); + + if (!participants.isEmpty()) { + throw new BadRequestException(NEED_TO_EXIT_ALL_ROOMS); + } + + member.delete(clockHolder.times()); + memberRepository.flush(); + memberRepository.delete(member); + rankingService.removeRanking(MemberMapper.toRankingInfo(member)); + fcmService.deleteTokenByMemberId(member.getId()); + } + + public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { + Long searchId = authMember.id(); + boolean isMe = confirmMe(searchId, memberId); + + if (!isMe) { + searchId = memberId; + } + MemberInfoSearchResponse memberInfoSearchResponse = findMemberInfo(searchId, isMe); + + return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse); + } + + @Transactional + public void modifyInfo(AuthMember authMember, ModifyMemberRequest modifyMemberRequest, String newProfileUri) { + validateNickname(modifyMemberRequest.nickname()); + Member member = memberSearchRepository.findMember(authMember.id()) + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + + RankingInfo beforeInfo = MemberMapper.toRankingInfo(member); + member.changeNickName(modifyMemberRequest.nickname()); + + boolean nickNameChanged = member.changeNickName(modifyMemberRequest.nickname()); + member.changeIntro(modifyMemberRequest.intro()); + member.changeProfileUri(newProfileUri); + memberRepository.save(member); + + RankingInfo afterInfo = MemberMapper.toRankingInfo(member); + rankingService.changeInfos(beforeInfo, afterInfo); + + if (nickNameChanged) { + changeNickname(authMember.id(), modifyMemberRequest.nickname()); + } + } + + public UpdateRanking getRankingInfo(AuthMember authMember) { + Member member = findMember(authMember.id()); + + return MemberMapper.toUpdateRanking(member); + } + + @Scheduled(cron = "0 11 * * * *") + public void updateAllRanking() { + List members = memberSearchRepository.findAllMembers(); + List updateRankings = members.stream() + .map(MemberMapper::toUpdateRanking) + .toList(); + + rankingService.updateScores(updateRankings); + } + + private void changeNickname(Long memberId, String changedName) { + List participants = participantSearchRepository.findAllRoomMangerByMemberId(memberId); + + for (Participant participant : participants) { + participant.getRoom().changeManagerNickname(changedName); + } + } + + private void validateNickname(String nickname) { + if (Objects.isNull(nickname)) { + return; + } + if (StringUtils.isEmpty(nickname) && memberRepository.existsByNickname(nickname)) { + throw new ConflictException(NICKNAME_CONFLICT); + } + } + + private Member signUp(Long socialId) { + Member member = MemberMapper.toMember(socialId); + Member savedMember = memberRepository.save(member); + saveMyEgg(savedMember); + rankingService.addRanking(MemberMapper.toRankingInfo(member), member.getTotalCertifyCount()); + + return savedMember; + } + + private void saveMyEgg(Member member) { + List items = getBasicEggs(); + List inventories = items.stream() + .map(item -> MemberMapper.toInventory(member.getId(), item)) + .toList(); + inventoryRepository.saveAll(inventories); + } + + private List getBasicEggs() { + List items = itemRepository.findAllById(List.of(BaseDataCode.MORNING_EGG, BaseDataCode.NIGHT_EGG)); + + if (items.isEmpty()) { + throw new BadRequestException(BASIC_SKIN_NOT_FOUND); + } + + return items; + } + + private MemberInfoSearchResponse findMemberInfo(Long searchId, boolean isMe) { + List memberInfos = memberSearchRepository.findMemberAndBadges(searchId, isMe); + + if (memberInfos.isEmpty()) { + throw new BadRequestException(MEMBER_NOT_FOUND); + } + + return MemberMapper.toMemberInfoSearchResponse(memberInfos); + } + + private boolean confirmMe(Long myId, Long memberId) { + return Objects.isNull(memberId) || myId.equals(memberId); + } +} diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java new file mode 100644 index 00000000..3d8c859b --- /dev/null +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -0,0 +1,99 @@ +package com.moabam.api.application.notification; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.notification.repository.NotificationRepository; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NotificationService { + + private static final String COMMON_TITLE = "모아밤"; + private static final String KNOCK_BODY = "[%s] - [%s]님이 콕콕콕!"; + private static final String CERTIFY_TIME_BODY = "[%s] - 5분 후 인증 시간입니다!"; + + private final ClockHolder clockHolder; + private final FcmService fcmService; + private final RoomService roomService; + private final MemberService memberService; + + private final NotificationRepository notificationRepository; + private final ParticipantSearchRepository participantSearchRepository; + + @Transactional + public void sendKnock(Long roomId, Long targetId, Long memberId) { + validateConflictKnock(roomId, targetId, memberId); + String fcmToken = fcmService.findTokenByMemberId(targetId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); + + String roomTitle = roomService.findRoom(roomId).getTitle(); + String memberNickname = memberService.findMember(memberId).getNickname(); + String notificationTitle = roomId.toString(); + + String notificationBody = String.format(KNOCK_BODY, roomTitle, memberNickname); + fcmService.sendAsync(fcmToken, notificationTitle, notificationBody); + notificationRepository.saveKnock(roomId, targetId, memberId); + } + + public void sendCouponIssueResult(Long memberId, String couponName, String body) { + String fcmToken = fcmService.findTokenByMemberId(memberId).orElse(null); + String notificationBody = String.format(body, couponName); + fcmService.sendAsync(fcmToken, COMMON_TITLE, notificationBody); + } + + @Scheduled(cron = "0 55 * * * *") + public void sendCertificationTime() { + int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; + List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime); + + participants.parallelStream().forEach(participant -> { + String roomTitle = participant.getRoom().getTitle(); + String notificationTitle = participant.getRoom().getId().toString(); + String notificationBody = String.format(CERTIFY_TIME_BODY, roomTitle); + String fcmToken = fcmService.findTokenByMemberId(participant.getMemberId()).orElse(null); + fcmService.sendAsync(fcmToken, notificationTitle, notificationBody); + }); + } + + public List getMyKnockStatusInRoom(Long memberId, Long roomId, List participants) { + List filteredParticipants = participants.stream() + .filter(participant -> !participant.getMemberId().equals(memberId)) + .toList(); + + Predicate knockPredicate = targetId -> + notificationRepository.existsKnockByKey(roomId, targetId, memberId); + + Map> knockStatus = filteredParticipants.stream() + .map(Participant::getMemberId) + .collect(Collectors.partitioningBy(knockPredicate)); + + return knockStatus.get(true); + } + + private void validateConflictKnock(Long roomId, Long targetId, Long memberId) { + if (notificationRepository.existsKnockByKey(roomId, targetId, memberId)) { + throw new ConflictException(ErrorMessage.CONFLICT_KNOCK); + } + } +} diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java new file mode 100644 index 00000000..e98b64eb --- /dev/null +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -0,0 +1,49 @@ +package com.moabam.api.application.payment; + +import java.util.Optional; + +import com.moabam.api.domain.payment.Order; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.api.dto.payment.PaymentResponse; +import com.moabam.api.dto.payment.RequestConfirmPaymentResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PaymentMapper { + + public static Payment toPayment(Long memberId, Product product) { + Order order = Order.builder() + .name(product.getName()) + .build(); + + return Payment.builder() + .memberId(memberId) + .product(product) + .order(order) + .totalAmount(product.getPrice()) + .build(); + } + + public static PaymentResponse toPaymentResponse(Payment payment) { + return Optional.ofNullable(payment) + .map(p -> PaymentResponse.builder() + .id(p.getId()) + .orderName(p.getOrder().getName()) + .discountAmount(p.getDiscountAmount()) + .totalAmount(p.getTotalAmount()) + .build()) + .orElse(null); + } + + public static RequestConfirmPaymentResponse toRequestConfirmPaymentResponse(Payment payment, + ConfirmTossPaymentResponse response) { + return RequestConfirmPaymentResponse.builder() + .payment(payment) + .paymentKey(response.paymentKey()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java new file mode 100644 index 00000000..80b388a8 --- /dev/null +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -0,0 +1,74 @@ +package com.moabam.api.application.payment; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.coupon.CouponService; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.payment.repository.PaymentSearchRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.dto.payment.RequestConfirmPaymentResponse; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.exception.TossPaymentException; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PaymentService { + + private final BugService bugService; + private final CouponService couponService; + private final TossPaymentService tossPaymentService; + private final PaymentRepository paymentRepository; + private final PaymentSearchRepository paymentSearchRepository; + + @Transactional + public void request(Long memberId, Long paymentId, PaymentRequest request) { + Payment payment = getById(paymentId); + payment.validateByMember(memberId); + payment.request(request.orderId()); + } + + @Transactional + public RequestConfirmPaymentResponse requestConfirm(Long memberId, ConfirmPaymentRequest request) { + Payment payment = getByOrderId(request.orderId()); + payment.validateInfo(memberId, request.amount()); + + try { + ConfirmTossPaymentResponse response = tossPaymentService.confirm(request); + return PaymentMapper.toRequestConfirmPaymentResponse(payment, response); + } catch (TossPaymentException exception) { + payment.fail(request.paymentKey()); + throw exception; + } + } + + @Transactional + public void confirm(Long memberId, Payment payment, String paymentKey) { + payment.confirm(paymentKey); + + if (payment.isCouponApplied()) { + couponService.discount(payment.getCouponWalletId(), memberId); + } + bugService.charge(memberId, payment.getProduct()); + } + + private Payment getById(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); + } + + private Payment getByOrderId(String orderId) { + return paymentSearchRepository.findByOrderId(orderId) + .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moabam/api/application/product/ProductMapper.java b/src/main/java/com/moabam/api/application/product/ProductMapper.java new file mode 100644 index 00000000..b847d9c1 --- /dev/null +++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java @@ -0,0 +1,41 @@ +package com.moabam.api.application.product; + +import java.util.List; + +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.product.ProductResponse; +import com.moabam.api.dto.product.ProductsResponse; +import com.moabam.api.dto.product.PurchaseProductResponse; +import com.moabam.global.common.util.StreamUtils; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ProductMapper { + + public static ProductResponse toProductResponse(Product product) { + return ProductResponse.builder() + .id(product.getId()) + .type(product.getType().name()) + .name(product.getName()) + .price(product.getPrice()) + .quantity(product.getQuantity()) + .build(); + } + + public static ProductsResponse toProductsResponse(List products) { + return ProductsResponse.builder() + .products(StreamUtils.map(products, ProductMapper::toProductResponse)) + .build(); + } + + public static PurchaseProductResponse toPurchaseProductResponse(Payment payment) { + return PurchaseProductResponse.builder() + .paymentId(payment.getId()) + .orderName(payment.getOrder().getName()) + .price(payment.getTotalAmount()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/ranking/RankingMapper.java b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java new file mode 100644 index 00000000..817ccdc8 --- /dev/null +++ b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java @@ -0,0 +1,39 @@ +package com.moabam.api.application.ranking; + +import java.util.List; + +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.TopRankingInfo; +import com.moabam.api.dto.ranking.TopRankingResponse; +import com.moabam.api.dto.ranking.UpdateRanking; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class RankingMapper { + + public static TopRankingInfo topRankingResponse(int rank, long score, RankingInfo rankInfo) { + return TopRankingInfo.builder() + .rank(rank) + .score(score) + .nickname(rankInfo.nickname()) + .image(rankInfo.image()) + .memberId(rankInfo.memberId()) + .build(); + } + + public static TopRankingInfo topRankingResponse(int rank, UpdateRanking updateRanking) { + return TopRankingInfo.builder() + .rank(rank + 1) + .score(updateRanking.score()) + .nickname(updateRanking.rankingInfo().nickname()) + .image(updateRanking.rankingInfo().image()) + .memberId(updateRanking.rankingInfo().memberId()) + .build(); + } + + public static TopRankingResponse topRankingResponses(TopRankingInfo myRanking, List topRankings) { + return TopRankingResponse.builder().topRankings(topRankings).myRanking(myRanking).build(); + } +} diff --git a/src/main/java/com/moabam/api/application/ranking/RankingService.java b/src/main/java/com/moabam/api/application/ranking/RankingService.java new file mode 100644 index 00000000..651093f4 --- /dev/null +++ b/src/main/java/com/moabam/api/application/ranking/RankingService.java @@ -0,0 +1,86 @@ +package com.moabam.api.application.ranking; + +import static java.util.Objects.*; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; + +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.TopRankingInfo; +import com.moabam.api.dto.ranking.TopRankingResponse; +import com.moabam.api.dto.ranking.UpdateRanking; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class RankingService { + + private static final String RANKING = "Ranking"; + private static final int START_INDEX = 0; + private static final int LIMIT_INDEX = 9; + + private final ObjectMapper objectMapper; + private final ZSetRedisRepository zSetRedisRepository; + + public void addRanking(RankingInfo rankingInfo, Long totalCertifyCount) { + zSetRedisRepository.add(RANKING, rankingInfo, totalCertifyCount); + } + + public void updateScores(List updateRankings) { + updateRankings.forEach( + updateRanking -> zSetRedisRepository.add(RANKING, updateRanking.rankingInfo(), updateRanking.score())); + } + + public void changeInfos(RankingInfo before, RankingInfo after) { + zSetRedisRepository.changeMember(RANKING, before, after); + } + + public void removeRanking(RankingInfo rankingInfo) { + zSetRedisRepository.delete(RANKING, rankingInfo); + } + + public TopRankingResponse getMemberRanking(UpdateRanking myRankingInfo) { + List topRankings = getTopRankings(); + Long myRanking = zSetRedisRepository.reverseRank(RANKING, myRankingInfo.rankingInfo()); + + Optional myTopRanking = topRankings.stream() + .filter(topRankingInfo -> Objects.equals(topRankingInfo.memberId(), myRankingInfo.rankingInfo().memberId())) + .findFirst(); + + if (myTopRanking.isPresent()) { + myRanking = (long)myTopRanking.get().rank(); + } + + TopRankingInfo myRankingInfoResponse = RankingMapper.topRankingResponse(myRanking.intValue(), myRankingInfo); + + return RankingMapper.topRankingResponses(myRankingInfoResponse, topRankings); + } + + private List getTopRankings() { + Set> topRankings = zSetRedisRepository.rangeJson(RANKING, START_INDEX, + LIMIT_INDEX); + + Set scoreSet = new HashSet<>(); + List topRankingInfo = new ArrayList<>(); + + for (ZSetOperations.TypedTuple topRanking : topRankings) { + long score = requireNonNull(topRanking.getScore()).longValue(); + scoreSet.add(score); + + RankingInfo rankingInfo = objectMapper.convertValue(topRanking.getValue(), RankingInfo.class); + topRankingInfo.add(RankingMapper.topRankingResponse(scoreSet.size(), score, rankingInfo)); + } + + return topRankingInfo; + } +} diff --git a/src/main/java/com/moabam/api/application/report/ReportMapper.java b/src/main/java/com/moabam/api/application/report/ReportMapper.java new file mode 100644 index 00000000..7cada261 --- /dev/null +++ b/src/main/java/com/moabam/api/application/report/ReportMapper.java @@ -0,0 +1,23 @@ +package com.moabam.api.application.report; + +import com.moabam.api.domain.report.Report; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class ReportMapper { + + public static Report toReport(Long reporterId, Long reportedMemberId, + Room room, Certification certification, String description) { + return Report.builder() + .reporterId(reporterId) + .reportedMemberId(reportedMemberId) + .certification(certification) + .room(room) + .description(description) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/report/ReportService.java b/src/main/java/com/moabam/api/application/report/ReportService.java new file mode 100644 index 00000000..5735c63a --- /dev/null +++ b/src/main/java/com/moabam/api/application/report/ReportService.java @@ -0,0 +1,61 @@ +package com.moabam.api.application.report; + +import static java.util.Objects.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.CertificationService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.report.Report; +import com.moabam.api.domain.report.repository.ReportRepository; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.api.dto.report.ReportRequest; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReportService { + + private final MemberService memberService; + private final RoomService roomService; + private final CertificationService certificationService; + private final ReportRepository reportRepository; + + @Transactional + public void report(AuthMember authMember, ReportRequest reportRequest) { + validateNoReportSubject(reportRequest.reportedId()); + Report report = createReport(authMember.id(), reportRequest); + reportRepository.save(report); + } + + private Report createReport(Long reporterId, ReportRequest reportRequest) { + Member reportedMember = memberService.findMember(reportRequest.reportedId()); + + Certification certification = null; + if (nonNull(reportRequest.certificationId())) { + certification = certificationService.findCertification(reportRequest.certificationId()); + } + + Room room = null; + if (nonNull(reportRequest.roomId())) { + room = roomService.findRoom(reportRequest.roomId()); + } + + return ReportMapper.toReport(reporterId, reportedMember.getId(), + room, certification, reportRequest.description()); + } + + private void validateNoReportSubject(Long reportedId) { + if (isNull(reportedId)) { + throw new BadRequestException(ErrorMessage.REPORT_REQUEST_ERROR); + } + } +} diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java new file mode 100644 index 00000000..c4e86434 --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -0,0 +1,208 @@ +package com.moabam.api.application.room; + +import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.member.BadgeService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.mapper.CertificationsMapper; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomExp; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationRepository; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.room.repository.DailyRoomCertificationRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CertifiedMemberInfo; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.common.util.UrlSubstringParser; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CertificationService { + + private static final int REQUIRED_ROOM_CERTIFICATION = 75; + + private final RoutineRepository routineRepository; + private final CertificationRepository certificationRepository; + private final ParticipantSearchRepository participantSearchRepository; + private final CertificationsSearchRepository certificationsSearchRepository; + private final DailyRoomCertificationRepository dailyRoomCertificationRepository; + private final DailyMemberCertificationRepository dailyMemberCertificationRepository; + private final MemberService memberService; + private final BadgeService badgeService; + private final BugService bugService; + private final ClockHolder clockHolder; + + @Transactional + public CertifiedMemberInfo getCertifiedMemberInfo(Long memberId, Long roomId, List imageUrls) { + LocalDate today = clockHolder.date(); + Participant participant = participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + Room room = participant.getRoom(); + Member member = memberService.findMember(memberId); + BugType bugType = switch (room.getRoomType()) { + case MORNING -> BugType.MORNING; + case NIGHT -> BugType.NIGHT; + }; + + validateCertifyTime(clockHolder.times(), room.getCertifyTime()); + validateAlreadyCertified(memberId, roomId, today); + + certifyMember(memberId, roomId, participant, member, imageUrls); + + return CertificationsMapper.toCertifiedMemberInfo(today, bugType, room, member); + } + + @Transactional + public void certifyRoom(CertifiedMemberInfo certifyInfo) { + LocalDate date = certifyInfo.date(); + BugType bugType = certifyInfo.bugType(); + Room room = certifyInfo.room(); + Member member = certifyInfo.member(); + + Optional dailyRoomCertification = + certificationsSearchRepository.findDailyRoomCertification(room.getId(), date); + + if (dailyRoomCertification.isEmpty()) { + certifyRoomIfAvailable(room.getId(), date, room, bugType, room.getLevel()); + return; + } + + bugService.reward(member, bugType, room.getLevel()); + } + + public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { + return dailyMemberCertificationRepository.existsByMemberIdAndRoomIdAndCreatedAtBetween(memberId, roomId, + date.atStartOfDay(), date.atTime(LocalTime.MAX)); + } + + public boolean existsRoomCertification(Long roomId, LocalDate date) { + return dailyRoomCertificationRepository.existsByRoomIdAndCertifiedAt(roomId, date); + } + + public boolean existsAnyMemberCertification(Long roomId, LocalDate date) { + return dailyMemberCertificationRepository.existsByRoomIdAndCreatedAtBetween(roomId, date.atStartOfDay(), + date.atTime(LocalTime.MAX)); + } + + public Certification findCertification(Long certificationId) { + return certificationRepository.findById(certificationId) + .orElseThrow(() -> new NotFoundException(CERTIFICATION_NOT_FOUND)); + } + + private void validateCertifyTime(LocalDateTime now, int certifyTime) { + LocalTime targetTime = LocalTime.of(certifyTime, 0); + LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); + + if (certifyTime == MIDNIGHT_HOUR && now.getHour() != MIDNIGHT_HOUR) { + targetDateTime = targetDateTime.plusDays(1); + } + + LocalDateTime plusTenMinutes = targetDateTime.plusMinutes(10); + + if (now.isBefore(targetDateTime) || now.isAfter(plusTenMinutes)) { + throw new BadRequestException(INVALID_CERTIFY_TIME); + } + } + + private void validateAlreadyCertified(Long memberId, Long roomId, LocalDate today) { + if (certificationsSearchRepository.findDailyMemberCertification(memberId, roomId, today).isPresent()) { + throw new BadRequestException(DUPLICATED_DAILY_MEMBER_CERTIFICATION); + } + } + + private void certifyMember(Long memberId, Long roomId, Participant participant, Member member, List urls) { + DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, + roomId, participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + member.increaseTotalCertifyCount(); + badgeService.createBadge(member.getId(), member.getTotalCertifyCount()); + participant.updateCertifyCount(); + + saveNewCertifications(memberId, urls); + } + + private void saveNewCertifications(Long memberId, List imageUrls) { + List certifications = new ArrayList<>(); + + for (String imageUrl : imageUrls) { + Long routineId = Long.parseLong(UrlSubstringParser.parseUrl(imageUrl, "_")); + Routine routine = routineRepository.findById(routineId).orElseThrow(() -> new NotFoundException( + ROUTINE_NOT_FOUND)); + + Certification certification = CertificationsMapper.toCertification(routine, memberId, imageUrl); + certifications.add(certification); + } + + certificationRepository.saveAll(certifications); + } + + private void certifyRoomIfAvailable(Long roomId, LocalDate today, Room room, BugType bugType, int roomLevel) { + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + if (completePercentage >= REQUIRED_ROOM_CERTIFICATION) { + DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( + roomId, today); + + dailyRoomCertificationRepository.save(createDailyRoomCertification); + int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); + + provideBugToCompletedMembers(bugType, dailyMemberCertifications, expAppliedRoomLevel); + } + } + + private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { + double completePercentage = ((double)certifiedMembersCount / currentsMembersCount) * 100; + + return Math.round(completePercentage * 100) / 100.0; + } + + private int getRoomLevelAfterExpApply(int roomLevel, Room room) { + int requireExp = RoomExp.of(roomLevel).getTotalExp(); + room.gainExp(); + + if (room.getExp() == requireExp) { + room.levelUp(); + } + + return room.getLevel(); + } + + private void provideBugToCompletedMembers(BugType bugType, List dailyMemberCertifications, + int expAppliedRoomLevel) { + List memberIds = dailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + + memberService.getRoomMembers(memberIds) + .forEach(completedMember -> bugService.reward(completedMember, bugType, expAppliedRoomLevel)); + } +} diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java new file mode 100644 index 00000000..1f2ccdce --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -0,0 +1,240 @@ +package com.moabam.api.application.room; + +import static com.moabam.api.domain.room.RoomType.*; +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.List; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.mapper.ParticipantMapper; +import com.moabam.api.application.room.mapper.RoomMapper; +import com.moabam.api.application.room.mapper.RoutineMapper; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.api.dto.room.EnterRoomRequest; +import com.moabam.api.dto.room.ModifyRoomRequest; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ForbiddenException; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class RoomService { + + private final RoomRepository roomRepository; + private final RoutineRepository routineRepository; + private final ParticipantRepository participantRepository; + private final ParticipantSearchRepository participantSearchRepository; + private final DailyMemberCertificationRepository dailyMemberCertificationRepository; + private final CertificationService certificationService; + private final MemberService memberService; + private final ClockHolder clockHolder; + + @Transactional + public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { + Room room = RoomMapper.toRoomEntity(createRoomRequest); + List routines = RoutineMapper.toRoutineEntities(room, createRoomRequest.routines()); + Participant participant = ParticipantMapper.toParticipant(room, memberId); + + validateEnteredRoomCount(memberId, room.getRoomType()); + + Member member = memberService.findMember(memberId); + member.enterRoom(room.getRoomType()); + participant.enableManager(); + room.changeManagerNickname(member.getNickname()); + + Room savedRoom = roomRepository.save(room); + routineRepository.saveAll(routines); + participantRepository.save(participant); + + return savedRoom.getId(); + } + + @Transactional + public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomRequest) { + Participant participant = getParticipant(memberId, roomId); + validateManagerAuthorization(participant); + + Room room = participant.getRoom(); + room.changeTitle(modifyRoomRequest.title()); + room.changeAnnouncement(modifyRoomRequest.announcement()); + room.changePassword(modifyRoomRequest.password()); + room.changeMaxCount(modifyRoomRequest.maxUserCount()); + + if (room.getCertifyTime() != modifyRoomRequest.certifyTime()) { + validateChangeCertifyTime(roomId); + } + room.changeCertifyTime(modifyRoomRequest.certifyTime()); + } + + @Transactional + public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequest) { + Room room = roomRepository.findWithPessimisticLockByIdAndDeletedAtIsNull(roomId).orElseThrow( + () -> new NotFoundException(ROOM_NOT_FOUND)); + validateRoomEnter(memberId, enterRoomRequest.password(), room); + + Member member = memberService.findMember(memberId); + member.enterRoom(room.getRoomType()); + room.increaseCurrentUserCount(); + + Participant participant = ParticipantMapper.toParticipant(room, memberId); + participantRepository.save(participant); + } + + @Transactional + public void exitRoom(Long memberId, Long roomId) { + Participant participant = getParticipant(memberId, roomId); + Room room = participant.getRoom(); + + validateRoomExit(participant, room); + + Member member = memberService.findMember(memberId); + member.exitRoom(room.getRoomType()); + + participant.removeRoom(); + participantRepository.flush(); + participantRepository.delete(participant); + + if (!participant.isManager()) { + room.decreaseCurrentUserCount(); + return; + } + + roomRepository.delete(room); + } + + @Transactional + public void mandateManager(Long managerId, Long roomId, Long memberId) { + Participant managerParticipant = getParticipant(managerId, roomId); + Participant memberParticipant = getParticipant(memberId, roomId); + validateManagerAuthorization(managerParticipant); + + Room room = managerParticipant.getRoom(); + Member member = memberService.findMember(memberParticipant.getMemberId()); + room.changeManagerNickname(member.getNickname()); + + managerParticipant.disableManager(); + memberParticipant.enableManager(); + } + + @Transactional + public void deportParticipant(Long managerId, Long roomId, Long memberId) { + validateDeportParticipant(managerId, memberId); + Participant managerParticipant = getParticipant(managerId, roomId); + Participant memberParticipant = getParticipant(memberId, roomId); + validateManagerAuthorization(managerParticipant); + + Room room = managerParticipant.getRoom(); + memberParticipant.removeRoom(); + participantRepository.flush(); + participantRepository.delete(memberParticipant); + room.decreaseCurrentUserCount(); + + Member member = memberService.findMember(memberId); + member.exitRoom(room.getRoomType()); + } + + public boolean checkIfParticipant(Long memberId, Long roomId) { + try { + getParticipant(memberId, roomId); + return true; + } catch (NotFoundException e) { + return false; + } + } + + public Room findRoom(Long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + } + + private void validateChangeCertifyTime(Long roomId) { + if (certificationService.existsAnyMemberCertification(roomId, clockHolder.date())) { + throw new BadRequestException(UNAVAILABLE_TO_CHANGE_CERTIFY_TIME); + } + } + + private Participant getParticipant(Long memberId, Long roomId) { + return participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + } + + private void validateDeportParticipant(Long managerId, Long memberId) { + if (managerId.equals(memberId)) { + throw new BadRequestException(PARTICIPANT_DEPORT_ERROR); + } + } + + private void validateManagerAuthorization(Participant participant) { + if (!participant.isManager()) { + throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); + } + } + + private void validateRoomEnter(Long memberId, String requestPassword, Room room) { + validateEnteredRoomCount(memberId, room.getRoomType()); + validateCertifyTime(room); + + if (!StringUtils.isEmpty(requestPassword) && !room.getPassword().equals(requestPassword)) { + throw new BadRequestException(WRONG_ROOM_PASSWORD); + } + if (room.getCurrentUserCount() == room.getMaxUserCount()) { + throw new BadRequestException(ROOM_MAX_USER_REACHED); + } + } + + private void validateEnteredRoomCount(Long memberId, RoomType roomType) { + Member member = memberService.findMember(memberId); + + if (roomType.equals(MORNING) && member.getCurrentMorningCount() >= 3) { + throw new BadRequestException(MEMBER_ROOM_EXCEED); + } + if (roomType.equals(NIGHT) && member.getCurrentNightCount() >= 3) { + throw new BadRequestException(MEMBER_ROOM_EXCEED); + } + } + + private void validateCertifyTime(Room room) { + LocalDateTime now = clockHolder.times(); + LocalTime targetTime = LocalTime.of(room.getCertifyTime(), 0); + LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); + + LocalDateTime plusTenMinutes = targetDateTime.plusMinutes(10); + + if (now.isAfter(targetDateTime) && now.isBefore(plusTenMinutes)) { + throw new BadRequestException(ROOM_ENTER_FAILED); + } + } + + private void validateRoomExit(Participant participant, Room room) { + if (participant.isManager() && room.getCurrentUserCount() != 1) { + throw new BadRequestException(ROOM_EXIT_MANAGER_FAIL); + } + + if (dailyMemberCertificationRepository.existsByMemberIdAndRoomIdAndCreatedAtBetween(participant.getMemberId(), + room.getId(), clockHolder.date().atStartOfDay(), clockHolder.date().atTime(LocalTime.MAX))) { + throw new BadRequestException(CERTIFIED_ROOM_EXIT_FAILED); + } + } +} diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java new file mode 100644 index 00000000..f489d966 --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -0,0 +1,416 @@ +package com.moabam.api.application.room; + +import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static org.apache.commons.lang3.StringUtils.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.Period; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.notification.NotificationService; +import com.moabam.api.application.room.mapper.CertificationsMapper; +import com.moabam.api.application.room.mapper.ParticipantMapper; +import com.moabam.api.application.room.mapper.RoomMapper; +import com.moabam.api.application.room.mapper.RoutineMapper; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoomSearchRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CertificationImageResponse; +import com.moabam.api.dto.room.CertificationImagesResponse; +import com.moabam.api.dto.room.GetAllRoomResponse; +import com.moabam.api.dto.room.GetAllRoomsResponse; +import com.moabam.api.dto.room.ManageRoomResponse; +import com.moabam.api.dto.room.MyRoomResponse; +import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.api.dto.room.ParticipantResponse; +import com.moabam.api.dto.room.RoomDetailsResponse; +import com.moabam.api.dto.room.RoomHistoryResponse; +import com.moabam.api.dto.room.RoomsHistoryResponse; +import com.moabam.api.dto.room.RoutineResponse; +import com.moabam.api.dto.room.TodayCertificateRankResponse; +import com.moabam.api.dto.room.UnJoinedRoomCertificateRankResponse; +import com.moabam.api.dto.room.UnJoinedRoomDetailsResponse; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.ForbiddenException; +import com.moabam.global.error.exception.NotFoundException; + +import jakarta.annotation.Nullable; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class SearchService { + + private final RoomRepository roomRepository; + private final RoomSearchRepository roomSearchRepository; + private final RoutineRepository routineRepository; + private final ParticipantSearchRepository participantSearchRepository; + private final CertificationsSearchRepository certificationsSearchRepository; + private final InventorySearchRepository inventorySearchRepository; + private final CertificationService certificationService; + private final MemberService memberService; + private final NotificationService notificationService; + private final ClockHolder clockHolder; + + public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId, LocalDate date) { + Participant participant = participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + Room room = participant.getRoom(); + + String managerNickname = room.getManagerNickname(); + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, date); + List routineResponses = getRoutineResponses(roomId); + List todayCertificateRankResponses = getTodayCertificateRankResponses(memberId, + roomId, dailyMemberCertifications, date, room.getRoomType()); + List certifiedDates = getCertifiedDatesBeforeWeek(roomId); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room, date); + + return RoomMapper.toRoomDetailsResponse(memberId, room, managerNickname, routineResponses, certifiedDates, + todayCertificateRankResponses, completePercentage); + } + + public MyRoomsResponse getMyRooms(Long memberId) { + LocalDate today = clockHolder.date(); + List myRoomResponses = new ArrayList<>(); + List participants = participantSearchRepository.findNotDeletedAllByMemberId(memberId); + + for (Participant participant : participants) { + Room room = participant.getRoom(); + boolean isMemberCertified = certificationService.existsMemberCertification(memberId, room.getId(), today); + boolean isRoomCertified = certificationService.existsRoomCertification(room.getId(), today); + + myRoomResponses.add(RoomMapper.toMyRoomResponse(room, isMemberCertified, isRoomCertified)); + } + + return RoomMapper.toMyRoomsResponse(myRoomResponses); + } + + public RoomsHistoryResponse getJoinHistory(Long memberId) { + List participants = participantSearchRepository.findAllByMemberId(memberId); + List roomHistoryResponses = participants.stream() + .map(participant -> { + if (participant.getRoom() == null) { + return RoomMapper.toRoomHistoryResponse(null, participant.getDeletedRoomTitle(), participant); + } + + Room room = participant.getRoom(); + + return RoomMapper.toRoomHistoryResponse(room.getId(), room.getTitle(), participant); + }) + .toList(); + + return RoomMapper.toRoomsHistoryResponse(roomHistoryResponses); + } + + public ManageRoomResponse getRoomForModification(Long memberId, Long roomId) { + Participant participant = participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + + if (!participant.isManager()) { + throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); + } + + Room room = participant.getRoom(); + List routineResponses = getRoutineResponses(roomId); + List participants = participantSearchRepository.findAllByRoomId(roomId); + List memberIds = participants.stream() + .map(Participant::getMemberId) + .toList(); + List members = memberService.getRoomMembers(memberIds); + List participantResponses = new ArrayList<>(); + + for (Member member : members) { + int contributionPoint = calculateContributionPoint(member.getId(), participants, clockHolder.date()); + + participantResponses.add(ParticipantMapper.toParticipantResponse(member, contributionPoint)); + } + + return RoomMapper.toManageRoomResponse(room, memberId, routineResponses, participantResponses); + } + + public GetAllRoomsResponse getAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { + List getAllRoomResponse = new ArrayList<>(); + List rooms = new ArrayList<>(roomSearchRepository.findAllWithNoOffset(roomType, roomId)); + boolean hasNext = isHasNext(getAllRoomResponse, rooms); + + return RoomMapper.toSearchAllRoomsResponse(hasNext, getAllRoomResponse); + } + + public GetAllRoomsResponse searchRooms(String keyword, @Nullable RoomType roomType, @Nullable Long roomId) { + List getAllRoomResponse = new ArrayList<>(); + List rooms = new ArrayList<>(); + + if (roomId == null && roomType == null) { + rooms = new ArrayList<>(roomRepository.searchByKeyword(keyword)); + } + + if (roomId == null && roomType != null) { + rooms = new ArrayList<>(roomRepository.searchByKeywordAndRoomType(keyword, roomType.name())); + } + + if (roomId != null && roomType == null) { + rooms = new ArrayList<>(roomRepository.searchByKeywordAndRoomId(keyword, roomId)); + } + + if (roomId != null && roomType != null) { + rooms = new ArrayList<>( + roomRepository.searchByKeywordAndRoomIdAndRoomType(keyword, roomType.name(), roomId)); + } + + boolean hasNext = isHasNext(getAllRoomResponse, rooms); + + return RoomMapper.toSearchAllRoomsResponse(hasNext, getAllRoomResponse); + } + + public UnJoinedRoomDetailsResponse getUnJoinedRoomDetails(Long roomId) { + Room room = roomRepository.findById(roomId) + .orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + + List routines = routineRepository.findAllByRoomId(roomId); + List routineResponses = RoutineMapper.toRoutineResponses(routines); + List sortedDailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, clockHolder.date()); + List memberIds = sortedDailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + List members = memberService.getRoomMembers(memberIds); + List inventories = inventorySearchRepository.findDefaultInventories(memberIds, + room.getRoomType().name()); + List unJoinedRoomCertificateRankResponses = new ArrayList<>(); + + int rank = 1; + for (DailyMemberCertification certification : sortedDailyMemberCertifications) { + Member member = members.stream() + .filter(m -> m.getId().equals(certification.getMemberId())) + .findAny() + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + + Inventory inventory = inventories.stream() + .filter(i -> i.getMemberId().equals(member.getId())) + .findAny() + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); + + UnJoinedRoomCertificateRankResponse response = RoomMapper.toUnJoinedRoomCertificateRankResponse(member, + rank, inventory); + + unJoinedRoomCertificateRankResponses.add(response); + rank += 1; + } + + return RoomMapper.toUnJoinedRoomDetails(room, routineResponses, unJoinedRoomCertificateRankResponses); + } + + private boolean isHasNext(List getAllRoomResponse, List rooms) { + boolean hasNext = false; + + if (rooms.size() > ROOM_FIXED_SEARCH_SIZE) { + hasNext = true; + rooms.remove(ROOM_FIXED_SEARCH_SIZE); + } + + List roomIds = rooms.stream() + .map(Room::getId) + .toList(); + List routines = routineRepository.findAllByRoomIdIn(roomIds); + + for (Room room : rooms) { + List filteredRoutines = routines.stream() + .filter(routine -> routine.getRoom().getId().equals(room.getId())) + .toList(); + List filteredResponses = RoutineMapper.toRoutineResponses(filteredRoutines); + boolean isPassword = !isEmpty(room.getPassword()); + + getAllRoomResponse.add(RoomMapper.toSearchAllRoomResponse(room, filteredResponses, isPassword)); + } + + return hasNext; + } + + private List getRoutineResponses(Long roomId) { + List roomRoutines = routineRepository.findAllByRoomId(roomId); + + return RoutineMapper.toRoutineResponses(roomRoutines); + } + + private List getTodayCertificateRankResponses(Long memberId, Long roomId, + List dailyMemberCertifications, LocalDate date, RoomType roomType) { + + List certifications = certificationsSearchRepository.findCertifications(roomId, date); + List participants = participantSearchRepository.findAllWithDeletedByRoomId(roomId); + List members = memberService.getRoomMembers(participants.stream() + .map(Participant::getMemberId) + .distinct() + .toList()); + + List knocks = notificationService.getMyKnockStatusInRoom(memberId, roomId, participants); + + List memberIds = members.stream() + .map(Member::getId) + .toList(); + List inventories = inventorySearchRepository.findDefaultInventories(memberIds, roomType.name()); + + List responses = new ArrayList<>( + completedMembers(dailyMemberCertifications, members, certifications, participants, date, knocks, + inventories)); + + if (clockHolder.date().equals(date)) { + responses.addAll(uncompletedMembers(dailyMemberCertifications, members, participants, date, knocks, + inventories)); + } + + return responses; + } + + private List completedMembers( + List dailyMemberCertifications, List members, + List certifications, List participants, LocalDate date, List knocks, + List inventories) { + + List responses = new ArrayList<>(); + int rank = 1; + + for (DailyMemberCertification certification : dailyMemberCertifications) { + Member member = members.stream() + .filter(m -> m.getId().equals(certification.getMemberId())) + .findAny() + .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); + + Inventory inventory = inventories.stream() + .filter(i -> i.getMemberId().equals(member.getId())) + .findAny() + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); + + String awakeImage = inventory.getItem().getAwakeImage(); + String sleepImage = inventory.getItem().getSleepImage(); + + int contributionPoint = calculateContributionPoint(member.getId(), participants, date); + CertificationImagesResponse certificationImages = getCertificationImages(member.getId(), certifications); + boolean isNotificationSent = knocks.contains(member.getId()); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse(rank, member, + contributionPoint, awakeImage, sleepImage, certificationImages, isNotificationSent); + + rank += 1; + responses.add(response); + } + + return responses; + } + + private List uncompletedMembers( + List dailyMemberCertifications, List members, List participants, + LocalDate date, List knocks, List inventories) { + + List responses = new ArrayList<>(); + + List allMemberIds = participants.stream() + .filter(p -> p.getDeletedAt() == null) + .map(Participant::getMemberId) + .distinct() + .collect(Collectors.toList()); + + List certifiedMemberIds = dailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + + allMemberIds.removeAll(certifiedMemberIds); + + for (Long memberId : allMemberIds) { + Member member = members.stream() + .filter(m -> m.getId().equals(memberId)) + .findAny() + .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); + + Inventory inventory = inventories.stream() + .filter(i -> i.getMemberId().equals(member.getId())) + .findAny() + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); + + String awakeImage = inventory.getItem().getAwakeImage(); + String sleepImage = inventory.getItem().getSleepImage(); + + int contributionPoint = calculateContributionPoint(memberId, participants, date); + boolean isNotificationSent = knocks.contains(member.getId()); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( + NOT_COMPLETED_RANK, member, contributionPoint, awakeImage, sleepImage, null, + isNotificationSent); + + responses.add(response); + } + + return responses; + } + + private CertificationImagesResponse getCertificationImages(Long memberId, List certifications) { + List certificationImageResponses = certifications.stream() + .filter(certification -> certification.getMemberId().equals(memberId)) + .map(certification -> CertificationsMapper.toCertificateImageResponse(certification.getRoutine().getId(), + certification.getImage())) + .toList(); + + return CertificationsMapper.toCertificateImagesResponse(certificationImageResponses); + } + + private int calculateContributionPoint(Long memberId, List participants, LocalDate date) { + Participant participant = participants.stream() + .filter(p -> p.getMemberId().equals(memberId)) + .filter(p -> p.getDeletedAt() == null) + .findAny() + .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); + + int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), date).getDays() + 1; + + return (int)(((double)participant.getCertifyCount() / participatedDays) * 100); + } + + private List getCertifiedDatesBeforeWeek(Long roomId) { + List certifications = certificationsSearchRepository.findDailyRoomCertifications( + roomId, clockHolder.date()); + + return certifications.stream() + .map(DailyRoomCertification::getCertifiedAt) + .toList(); + } + + private double calculateCompletePercentage(int certifiedMembersCount, Room room, LocalDate date) { + if (!date.equals(clockHolder.date())) { + return 0; + } + + LocalDateTime now = clockHolder.times(); + LocalTime targetTime = LocalTime.of(room.getCertifyTime(), 0); + LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); + + List participants = participantSearchRepository.findAllByRoomIdBeforeDate(room.getId(), + targetDateTime); + + double completePercentage = ((double)certifiedMembersCount / participants.size()) * 100; + + return Math.round(completePercentage * 100) / 100.0; + } +} diff --git a/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java new file mode 100644 index 00000000..797f18db --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java @@ -0,0 +1,87 @@ +package com.moabam.api.application.room.mapper; + +import java.time.LocalDate; +import java.util.List; + +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.dto.room.CertificationImageResponse; +import com.moabam.api.dto.room.CertificationImagesResponse; +import com.moabam.api.dto.room.CertifiedMemberInfo; +import com.moabam.api.dto.room.TodayCertificateRankResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CertificationsMapper { + + public static CertificationImageResponse toCertificateImageResponse(Long routineId, String image) { + return CertificationImageResponse.builder() + .routineId(routineId) + .image(image) + .build(); + } + + public static CertificationImagesResponse toCertificateImagesResponse(List images) { + return CertificationImagesResponse.builder() + .images(images) + .build(); + } + + public static TodayCertificateRankResponse toTodayCertificateRankResponse(int rank, Member member, + int contributionPoint, String awakeImage, String sleepImage, + CertificationImagesResponse certificationImagesResponses, boolean isNotificationSent) { + + return TodayCertificateRankResponse.builder() + .rank(rank) + .memberId(member.getId()) + .nickname(member.getNickname()) + .isNotificationSent(isNotificationSent) + .profileImage(member.getProfileImage()) + .contributionPoint(contributionPoint) + .awakeImage(awakeImage) + .sleepImage(sleepImage) + .certificationImage(certificationImagesResponses) + .build(); + } + + public static DailyMemberCertification toDailyMemberCertification(Long memberId, Long roomId, + Participant participant) { + return DailyMemberCertification.builder() + .memberId(memberId) + .roomId(roomId) + .participant(participant) + .build(); + } + + public static DailyRoomCertification toDailyRoomCertification(Long roomId, LocalDate today) { + return DailyRoomCertification.builder() + .roomId(roomId) + .certifiedAt(today) + .build(); + } + + public static Certification toCertification(Routine routine, Long memberId, String image) { + return Certification.builder() + .routine(routine) + .memberId(memberId) + .image(image) + .build(); + } + + public static CertifiedMemberInfo toCertifiedMemberInfo(LocalDate date, BugType bugType, Room room, Member member) { + return CertifiedMemberInfo.builder() + .date(date) + .bugType(bugType) + .room(room) + .member(member) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/room/mapper/ParticipantMapper.java b/src/main/java/com/moabam/api/application/room/mapper/ParticipantMapper.java new file mode 100644 index 00000000..3a4ec1da --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/mapper/ParticipantMapper.java @@ -0,0 +1,29 @@ +package com.moabam.api.application.room.mapper; + +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.dto.room.ParticipantResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class ParticipantMapper { + + public static Participant toParticipant(Room room, Long memberId) { + return Participant.builder() + .room(room) + .memberId(memberId) + .build(); + } + + public static ParticipantResponse toParticipantResponse(Member member, int contributionPoint) { + return ParticipantResponse.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .contributionPoint(contributionPoint) + .profileImage(member.getProfileImage()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java new file mode 100644 index 00000000..143fac74 --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -0,0 +1,176 @@ +package com.moabam.api.application.room.mapper; + +import static org.apache.commons.lang3.StringUtils.*; + +import java.time.LocalDate; +import java.util.List; + +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomExp; +import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.api.dto.room.GetAllRoomResponse; +import com.moabam.api.dto.room.GetAllRoomsResponse; +import com.moabam.api.dto.room.ManageRoomResponse; +import com.moabam.api.dto.room.MyRoomResponse; +import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.api.dto.room.ParticipantResponse; +import com.moabam.api.dto.room.RoomDetailsResponse; +import com.moabam.api.dto.room.RoomHistoryResponse; +import com.moabam.api.dto.room.RoomsHistoryResponse; +import com.moabam.api.dto.room.RoutineResponse; +import com.moabam.api.dto.room.TodayCertificateRankResponse; +import com.moabam.api.dto.room.UnJoinedRoomCertificateRankResponse; +import com.moabam.api.dto.room.UnJoinedRoomDetailsResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RoomMapper { + + public static Room toRoomEntity(CreateRoomRequest createRoomRequest) { + return Room.builder() + .title(createRoomRequest.title()) + .password(createRoomRequest.password()) + .roomType(createRoomRequest.roomType()) + .certifyTime(createRoomRequest.certifyTime()) + .maxUserCount(createRoomRequest.maxUserCount()) + .build(); + } + + public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room, String managerNickname, + List routineResponses, List certifiedDates, + List todayCertificateRankResponses, double completePercentage) { + return RoomDetailsResponse.builder() + .roomId(room.getId()) + .roomCreatedAt(room.getCreatedAt()) + .myMemberId(memberId) + .title(room.getTitle()) + .managerNickName(managerNickname) + .roomImage(room.getRoomImage()) + .level(room.getLevel()) + .currentExp(room.getExp()) + .totalExp(RoomExp.of(room.getLevel()).getTotalExp()) + .roomType(room.getRoomType()) + .certifyTime(room.getCertifyTime()) + .currentUserCount(room.getCurrentUserCount()) + .maxUserCount(room.getMaxUserCount()) + .announcement(room.getAnnouncement()) + .completePercentage(completePercentage) + .certifiedDates(certifiedDates) + .routines(routineResponses) + .todayCertificateRank(todayCertificateRankResponses) + .build(); + } + + public static MyRoomResponse toMyRoomResponse(Room room, boolean isMemberCertifiedToday, + boolean isRoomCertifiedToday) { + return MyRoomResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .roomType(room.getRoomType()) + .certifyTime(room.getCertifyTime()) + .currentUserCount(room.getCurrentUserCount()) + .maxUserCount(room.getMaxUserCount()) + .obtainedBugs(room.getLevel()) + .isMemberCertifiedToday(isMemberCertifiedToday) + .isRoomCertifiedToday(isRoomCertifiedToday) + .build(); + } + + public static MyRoomsResponse toMyRoomsResponse(List myRoomResponses) { + return MyRoomsResponse.builder() + .participatingRooms(myRoomResponses) + .build(); + } + + public static RoomHistoryResponse toRoomHistoryResponse(Long roomId, String title, Participant participant) { + return RoomHistoryResponse.builder() + .roomId(roomId) + .title(title) + .createdAt(participant.getCreatedAt()) + .deletedAt(participant.getDeletedAt()) + .build(); + } + + public static RoomsHistoryResponse toRoomsHistoryResponse(List roomHistoryResponses) { + return RoomsHistoryResponse.builder() + .roomHistory(roomHistoryResponses) + .build(); + } + + public static ManageRoomResponse toManageRoomResponse(Room room, Long managerId, List routines, + List participantResponses) { + return ManageRoomResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .managerId(managerId) + .announcement(room.getAnnouncement()) + .roomType(room.getRoomType()) + .certifyTime(room.getCertifyTime()) + .maxUserCount(room.getMaxUserCount()) + .password(room.getPassword()) + .routines(routines) + .participants(participantResponses) + .build(); + } + + public static GetAllRoomResponse toSearchAllRoomResponse(Room room, List routineResponses, + boolean isPassword) { + return GetAllRoomResponse.builder() + .id(room.getId()) + .title(room.getTitle()) + .image(room.getRoomImage()) + .isPassword(isPassword) + .managerNickname(room.getManagerNickname()) + .level(room.getLevel()) + .roomType(room.getRoomType()) + .certifyTime(room.getCertifyTime()) + .currentUserCount(room.getCurrentUserCount()) + .maxUserCount(room.getMaxUserCount()) + .routines(routineResponses) + .build(); + } + + public static GetAllRoomsResponse toSearchAllRoomsResponse(boolean hasNext, + List getAllRoomResponse) { + return GetAllRoomsResponse.builder() + .hasNext(hasNext) + .rooms(getAllRoomResponse) + .build(); + } + + public static UnJoinedRoomDetailsResponse toUnJoinedRoomDetails(Room room, List routines, + List responses) { + return UnJoinedRoomDetailsResponse.builder() + .roomId(room.getId()) + .isPassword(!isEmpty(room.getPassword())) + .title(room.getTitle()) + .roomImage(room.getRoomImage()) + .level(room.getLevel()) + .currentExp(room.getExp()) + .totalExp(RoomExp.of(room.getLevel()).getTotalExp()) + .roomType(room.getRoomType()) + .certifyTime(room.getCertifyTime()) + .currentUserCount(room.getCurrentUserCount()) + .maxUserCount(room.getMaxUserCount()) + .announcement(room.getAnnouncement()) + .routines(routines) + .certifiedRanks(responses) + .build(); + } + + public static UnJoinedRoomCertificateRankResponse toUnJoinedRoomCertificateRankResponse(Member member, int rank, + Inventory inventory) { + return UnJoinedRoomCertificateRankResponse.builder() + .rank(rank) + .memberId(member.getId()) + .nickname(member.getNickname()) + .awakeImage(inventory.getItem().getAwakeImage()) + .sleepImage(inventory.getItem().getSleepImage()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java b/src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java new file mode 100644 index 00000000..b2c362c6 --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java @@ -0,0 +1,32 @@ +package com.moabam.api.application.room.mapper; + +import java.util.List; + +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.dto.room.RoutineResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RoutineMapper { + + public static List toRoutineEntities(Room room, List routinesRequest) { + return routinesRequest.stream() + .map(routine -> Routine.builder() + .room(room) + .content(routine) + .build()) + .toList(); + } + + public static List toRoutineResponses(List routines) { + return routines.stream() + .map(routine -> RoutineResponse.builder() + .routineId(routine.getId()) + .content(routine.getContent()) + .build()) + .toList(); + } +} diff --git a/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java b/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java new file mode 100644 index 00000000..e104dad0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.auth.repository; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.api.infrastructure.redis.HashRedisRepository; + +@Repository +public class TokenRepository { + + private static final int EXPIRE_DAYS = 14; + + private final HashRedisRepository hashRedisRepository; + + @Autowired + public TokenRepository(HashRedisRepository hashRedisRepository) { + this.hashRedisRepository = hashRedisRepository; + } + + public void saveToken(Long memberId, TokenSaveValue tokenSaveRequest, Role role) { + String tokenKey = parseTokenKey(memberId, role); + + hashRedisRepository.save(tokenKey, tokenSaveRequest, Duration.ofDays(EXPIRE_DAYS)); + } + + public TokenSaveValue getTokenSaveValue(Long memberId, Role role) { + String tokenKey = parseTokenKey(memberId, role); + return (TokenSaveValue)hashRedisRepository.get(tokenKey); + } + + public void delete(Long memberId, Role role) { + String tokenKey = parseTokenKey(memberId, role); + hashRedisRepository.delete(tokenKey); + } + + private String parseTokenKey(Long memberId, Role role) { + return role.name() + "_" + memberId; + } +} diff --git a/src/main/java/com/moabam/api/domain/bug/Bug.java b/src/main/java/com/moabam/api/domain/bug/Bug.java new file mode 100644 index 00000000..f581c870 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/Bug.java @@ -0,0 +1,88 @@ +package com.moabam.api.domain.bug; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Bug { + + @Column(name = "morning_bug", nullable = false) + @ColumnDefault("0") + private int morningBug; + + @Column(name = "night_bug", nullable = false) + @ColumnDefault("0") + private int nightBug; + + @Column(name = "golden_bug", nullable = false) + @ColumnDefault("0") + private int goldenBug; + + @Builder + private Bug(int morningBug, int nightBug, int goldenBug) { + this.morningBug = validateBugCount(morningBug); + this.nightBug = validateBugCount(nightBug); + this.goldenBug = validateBugCount(goldenBug); + } + + private int validateBugCount(int bug) { + if (bug < 0) { + throw new BadRequestException(INVALID_BUG_COUNT); + } + + return bug; + } + + public void use(BugType bugType, int count) { + int currentBug = getBug(bugType); + + validateEnoughBug(currentBug, count); + decrease(bugType, count); + } + + private int getBug(BugType bugType) { + return switch (bugType) { + case MORNING -> this.morningBug; + case NIGHT -> this.nightBug; + case GOLDEN -> this.goldenBug; + }; + } + + private void validateEnoughBug(int currentBug, int count) { + if (currentBug < count) { + throw new BadRequestException(BUG_NOT_ENOUGH); + } + } + + private void decrease(BugType bugType, int count) { + switch (bugType) { + case MORNING -> this.morningBug -= count; + case NIGHT -> this.nightBug -= count; + case GOLDEN -> this.goldenBug -= count; + } + } + + public void increase(BugType bugType, int count) { + switch (bugType) { + case MORNING -> this.morningBug += count; + case NIGHT -> this.nightBug += count; + case GOLDEN -> this.goldenBug += count; + } + } + + public void charge(int count) { + this.goldenBug += count; + } +} diff --git a/src/main/java/com/moabam/api/domain/bug/BugActionType.java b/src/main/java/com/moabam/api/domain/bug/BugActionType.java new file mode 100644 index 00000000..24dcba45 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/BugActionType.java @@ -0,0 +1,10 @@ +package com.moabam.api.domain.bug; + +public enum BugActionType { + + REWARD, + CHARGE, + USE, + REFUND, + COUPON; +} diff --git a/src/main/java/com/moabam/api/domain/bug/BugHistory.java b/src/main/java/com/moabam/api/domain/bug/BugHistory.java new file mode 100644 index 00000000..9c08f4b4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/BugHistory.java @@ -0,0 +1,71 @@ +package com.moabam.api.domain.bug; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import com.moabam.api.domain.payment.Payment; +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "bug_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class BugHistory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "payment_id") + private Payment payment; + + @Enumerated(value = EnumType.STRING) + @Column(name = "bug_type", nullable = false) + private BugType bugType; + + @Enumerated(value = EnumType.STRING) + @Column(name = "action_type", nullable = false) + private BugActionType actionType; + + @Column(name = "quantity", nullable = false) + private int quantity; + + @Builder + private BugHistory(Long memberId, Payment payment, BugType bugType, BugActionType actionType, int quantity) { + this.memberId = requireNonNull(memberId); + this.payment = payment; + this.bugType = requireNonNull(bugType); + this.actionType = requireNonNull(actionType); + this.quantity = validateQuantity(quantity); + } + + private int validateQuantity(int quantity) { + if (quantity < 0) { + throw new BadRequestException(INVALID_QUANTITY); + } + + return quantity; + } +} diff --git a/src/main/java/com/moabam/api/domain/bug/BugType.java b/src/main/java/com/moabam/api/domain/bug/BugType.java new file mode 100644 index 00000000..9aa0535b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/BugType.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.bug; + +public enum BugType { + + MORNING, + NIGHT, + GOLDEN; + + public boolean isGoldenBug() { + return this == GOLDEN; + } +} diff --git a/src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java b/src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java new file mode 100644 index 00000000..191d486e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.bug.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.bug.BugHistory; + +public interface BugHistoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java new file mode 100644 index 00000000..8c5f569a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java @@ -0,0 +1,38 @@ +package com.moabam.api.domain.bug.repository; + +import static com.moabam.api.domain.bug.QBugHistory.*; +import static com.moabam.api.domain.payment.QPayment.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.dto.bug.BugHistoryWithPayment; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class BugHistorySearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findByMemberIdWithPayment(Long memberId) { + return jpaQueryFactory.select(Projections.constructor( + BugHistoryWithPayment.class, + bugHistory.id, + bugHistory.bugType, + bugHistory.actionType, + bugHistory.quantity, + bugHistory.createdAt, + payment) + ) + .from(bugHistory) + .leftJoin(bugHistory.payment, payment) + .where(bugHistory.memberId.eq(memberId)) + .orderBy(bugHistory.createdAt.desc()) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java new file mode 100644 index 00000000..a10a11a4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java @@ -0,0 +1,100 @@ +package com.moabam.api.domain.coupon; + +import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.time.LocalDate; +import java.util.Optional; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "coupon") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Coupon extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false, unique = true, length = 20) + private String name; + + @ColumnDefault("1") + @Column(name = "point", nullable = false) + private int point; + + @ColumnDefault("1") + @Column(name = "max_count", nullable = false) + private int maxCount; + + @ColumnDefault("''") + @Column(name = "description", length = 50) + private String description; + + @Enumerated(value = EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @Column(name = "start_at", unique = true, nullable = false) + private LocalDate startAt; + + @Column(name = "open_at", nullable = false) + private LocalDate openAt; + + @Column(name = "admin_id", updatable = false, nullable = false) + private Long adminId; + + @Builder + private Coupon(String name, String description, int point, int maxCount, CouponType type, LocalDate startAt, + LocalDate openAt, Long adminId) { + this.name = requireNonNull(name); + this.point = validatePoint(point); + this.maxCount = validateStock(maxCount); + this.description = Optional.ofNullable(description).orElse(BLANK); + this.type = requireNonNull(type); + this.startAt = requireNonNull(startAt); + this.openAt = requireNonNull(openAt); + this.adminId = requireNonNull(adminId); + } + + @Override + public String toString() { + return String.format("Coupon{startAt=%s, openAt=%s}", startAt, openAt); + } + + private int validatePoint(int point) { + if (point < 1) { + throw new BadRequestException(INVALID_COUPON_POINT); + } + + return point; + } + + private int validateStock(int stock) { + if (stock < 1) { + throw new BadRequestException(INVALID_COUPON_STOCK); + } + + return stock; + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/CouponType.java b/src/main/java/com/moabam/api/domain/coupon/CouponType.java new file mode 100644 index 00000000..ddcfe57a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/CouponType.java @@ -0,0 +1,60 @@ +package com.moabam.api.domain.coupon; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import com.moabam.api.domain.bug.BugType; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum CouponType { + + MORNING("아침"), + NIGHT("저녁"), + GOLDEN("황금"), + DISCOUNT("할인"); + + private final String name; + private static final Map COUPON_TYPE_MAP; + + static { + COUPON_TYPE_MAP = Collections.unmodifiableMap(Arrays.stream(values()) + .collect(Collectors.toMap(CouponType::getName, Function.identity()))); + } + + public static CouponType from(String name) { + return Optional.ofNullable(COUPON_TYPE_MAP.get(name)) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_TYPE)); + } + + public boolean isDiscount() { + return this == CouponType.DISCOUNT; + } + + public BugType getBugType() { + if (this == CouponType.MORNING) { + return BugType.MORNING; + } + + if (this == CouponType.NIGHT) { + return BugType.NIGHT; + } + + if (this == CouponType.GOLDEN) { + return BugType.GOLDEN; + } + + throw new BadRequestException(ErrorMessage.INVALID_DISCOUNT_COUPON); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java b/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java new file mode 100644 index 00000000..80994081 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java @@ -0,0 +1,44 @@ +package com.moabam.api.domain.coupon; + +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "coupon_wallet") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class CouponWallet extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @JoinColumn(name = "coupon_id", nullable = false) + @ManyToOne(fetch = FetchType.LAZY) + private Coupon coupon; + + private CouponWallet(Long memberId, Coupon coupon) { + this.memberId = memberId; + this.coupon = coupon; + } + + public static CouponWallet create(Long memberId, Coupon coupon) { + return new CouponWallet(memberId, coupon); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java new file mode 100644 index 00000000..0381f236 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -0,0 +1,83 @@ +package com.moabam.api.domain.coupon.repository; + +import static java.util.Objects.*; + +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponManageRepository { + + private static final String COUPON_COUNT_KEY = "%s_COUPON_COUNT_KEY"; + private static final int EXPIRE_DAYS = 2; + + private final ZSetRedisRepository zSetRedisRepository; + private final ValueRedisRepository valueRedisRepository; + + public void addIfAbsentQueue(String couponName, Long memberId, double registerTime) { + zSetRedisRepository.addIfAbsent( + requireNonNull(couponName), + requireNonNull(memberId), + registerTime, + EXPIRE_DAYS + ); + } + + public Set rangeQueue(String couponName, long start, long end) { + return zSetRedisRepository + .range(requireNonNull(couponName), start, end) + .stream() + .map(memberId -> Long.parseLong(String.valueOf(memberId))) + .collect(Collectors.toSet()); + } + + public boolean hasValue(String couponName, Long memberId) { + return Objects.nonNull(zSetRedisRepository.score(requireNonNull(couponName), memberId)); + } + + public int sizeQueue(String couponName) { + return zSetRedisRepository + .size(requireNonNull(couponName)) + .intValue(); + } + + public int rankQueue(String couponName, Long memberId) { + return zSetRedisRepository + .rank(requireNonNull(couponName), requireNonNull(memberId)) + .intValue(); + } + + public int getCount(String couponName) { + String couponCountKey = String.format(COUPON_COUNT_KEY, requireNonNull(couponName)); + String count = valueRedisRepository.get(couponCountKey); + + if (isNull(count)) { + return 0; + } + + return Integer.parseInt(count); + } + + public void increase(String couponName, long count) { + String couponCountKey = String.format(COUPON_COUNT_KEY, requireNonNull(couponName)); + valueRedisRepository.increment(couponCountKey, count); + } + + public void deleteQueue(String couponName) { + valueRedisRepository.delete(requireNonNull(couponName)); + } + + public void deleteCount(String couponName) { + String couponCountKey = String.format(COUPON_COUNT_KEY, requireNonNull(couponName)); + valueRedisRepository.delete(couponCountKey); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java new file mode 100644 index 00000000..f236858d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java @@ -0,0 +1,21 @@ +package com.moabam.api.domain.coupon.repository; + +import java.time.LocalDate; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.coupon.Coupon; + +public interface CouponRepository extends JpaRepository { + + boolean existsByName(String couponName); + + boolean existsByStartAt(LocalDate startAt); + + Optional findByStartAt(LocalDate startAt); + + Optional findByNameAndStartAt(String couponName, LocalDate startAt); + + boolean existsByNameAndStartAt(String couponName, LocalDate startAt); +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java new file mode 100644 index 00000000..703847eb --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java @@ -0,0 +1,49 @@ +package com.moabam.api.domain.coupon.repository; + +import static com.moabam.api.domain.coupon.QCoupon.*; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAllByStatus(LocalDate now, CouponStatusRequest couponStatus) { + return jpaQueryFactory.selectFrom(coupon) + .where(filterStatus(now, couponStatus)) + .orderBy(coupon.startAt.asc()) + .fetch(); + } + + private BooleanExpression filterStatus(LocalDate now, CouponStatusRequest couponStatus) { + // 모든 쿠폰 (금일 발급 가능한 쿠폰 포함) + if (couponStatus.opened() && couponStatus.ended()) { + return null; + } + + // 쿠폰 정보 오픈 중인 쿠폰들 (금일 발급 가능한 쿠폰 포함) + if (couponStatus.opened()) { + return coupon.openAt.loe(now).and(coupon.startAt.goe(now)); + } + + // 종료된 쿠폰들 + if (couponStatus.ended()) { + return coupon.startAt.lt(now); + } + + // 금일 발급 가능한 쿠폰 + return coupon.startAt.eq(now); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java new file mode 100644 index 00000000..48da8ca1 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.coupon.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.coupon.CouponWallet; + +public interface CouponWalletRepository extends JpaRepository { + + Optional findByIdAndMemberId(Long id, Long memberId); +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java new file mode 100644 index 00000000..9a931864 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java @@ -0,0 +1,44 @@ +package com.moabam.api.domain.coupon.repository; + +import static com.moabam.api.domain.coupon.QCoupon.*; +import static com.moabam.api.domain.coupon.QCouponWallet.*; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponWalletSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAllByIdAndMemberId(Long couponWalletId, Long memberId) { + return jpaQueryFactory + .selectFrom(couponWallet) + .join(couponWallet.coupon, coupon).fetchJoin() + .where( + DynamicQuery.generateEq(couponWalletId, couponWallet.id::eq), + DynamicQuery.generateEq(memberId, couponWallet.memberId::eq) + ) + .fetch(); + } + + public Optional findByIdAndMemberId(Long couponWalletId, Long memberId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(couponWallet) + .join(couponWallet.coupon, coupon).fetchJoin() + .where( + couponWallet.id.eq(couponWalletId), + couponWallet.memberId.eq(memberId)) + .fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/domain/image/ImageName.java b/src/main/java/com/moabam/api/domain/image/ImageName.java new file mode 100644 index 00000000..6f2a34db --- /dev/null +++ b/src/main/java/com/moabam/api/domain/image/ImageName.java @@ -0,0 +1,35 @@ +package com.moabam.api.domain.image; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.time.LocalDate; +import java.util.UUID; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class ImageName { + + private static final String CERTIFICATION_PATH = "certifications" + DELIMITER + LocalDate.now() + DELIMITER; + private static final String PROFILE_IMAGE = "members/profile" + DELIMITER; + private static final String BIRD_SKIN = "moabam/skins" + DELIMITER; + private static final String DEFAULT = "moabam/default" + DELIMITER; + + private final String fileName; + + public static ImageName of(MultipartFile file, ImageType imageType) { + return switch (imageType) { + case CERTIFICATION -> + new ImageName(CERTIFICATION_PATH + file.getName() + "_" + UUID.randomUUID() + IMAGE_EXTENSION); + case PROFILE_IMAGE -> + new ImageName(PROFILE_IMAGE + file.getName() + "_" + UUID.randomUUID() + IMAGE_EXTENSION); + case BIRD_SKIN -> new ImageName(BIRD_SKIN + file.getName() + IMAGE_EXTENSION); + case DEFAULT -> new ImageName(DEFAULT + file.getName() + IMAGE_EXTENSION); + }; + } +} diff --git a/src/main/java/com/moabam/api/domain/image/ImageResizer.java b/src/main/java/com/moabam/api/domain/image/ImageResizer.java new file mode 100644 index 00000000..82b2f1d4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/image/ImageResizer.java @@ -0,0 +1,122 @@ +package com.moabam.api.domain.image; + +import static com.moabam.global.common.util.GlobalConstant.DELIMITER; +import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE; +import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE_SIZE; +import static com.moabam.global.error.model.ErrorMessage.S3_RESIZE_ERROR; +import static java.util.Objects.requireNonNull; + +import java.awt.Graphics; +import java.awt.Image; +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + +import javax.imageio.ImageIO; + +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.global.error.exception.BadRequestException; + +import lombok.Builder; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; + +@Getter +@Slf4j +public class ImageResizer { + + private static final int MAX_IMAGE_SIZE = 1024 * 1024 * 10; + private static final String IMAGE_FORMAT_PREFIX = "image/"; + private static final int FORMAT_INDEX = 1; + + private final MultipartFile image; + private final String fileName; + private MultipartFile resizedImage; + + @Builder + public ImageResizer(MultipartFile image, String fileName) { + this.image = validate(image); + this.fileName = fileName; + } + + public MultipartFile validate(MultipartFile image) { + if (isNotImage(image)) { + throw new BadRequestException(S3_INVALID_IMAGE); + } + if (image.getSize() > MAX_IMAGE_SIZE) { + throw new BadRequestException(S3_INVALID_IMAGE_SIZE); + } + + return image; + } + + private boolean isNotImage(MultipartFile image) { + String contentType = requireNonNull(image.getContentType()); + + return !contentType.startsWith(IMAGE_FORMAT_PREFIX); + } + + public void resizeImageToFixedSize(ImageType imageType) { + ImageSize imageSize = switch (imageType) { + case PROFILE_IMAGE -> ImageSize.PROFILE_IMAGE; + case CERTIFICATION -> ImageSize.CERTIFICATION_IMAGE; + case BIRD_SKIN -> ImageSize.BIRD_SKIN; + case DEFAULT -> ImageSize.CAGE; + }; + + BufferedImage bufferedImage = getBufferedImage(); + + int width = imageSize.getWidth(); + int height = getResizedHeight(width, bufferedImage); + BufferedImage scaledImage = resize(bufferedImage, width, height); + + byte[] bytes = toByteArray(scaledImage); + this.resizedImage = toMultipartFile(bytes); + } + + private int getResizedHeight(int width, BufferedImage bufferedImage) { + double ratio = (double)width / bufferedImage.getWidth(); + + return (int)(bufferedImage.getHeight() * ratio); + } + + private BufferedImage resize(BufferedImage image, int width, int height) { + BufferedImage canvas = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB); + + Graphics graphics = canvas.getGraphics(); + graphics.drawImage(image.getScaledInstance(width, height, Image.SCALE_SMOOTH), 0, 0, null); + graphics.dispose(); + + return canvas; + } + + private byte[] toByteArray(final BufferedImage result) { + try { + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + ImageIO.write(result, getFormat(), byteArrayOutputStream); + + return byteArrayOutputStream.toByteArray(); + } catch (IOException e) { + log.error("이미지 리사이징 에러", e); + throw new BadRequestException(S3_RESIZE_ERROR); + } + } + + private String getFormat() { + return requireNonNull(image.getContentType()).split(DELIMITER)[FORMAT_INDEX]; + } + + private BufferedImage getBufferedImage() { + try { + return ImageIO.read(image.getInputStream()); + } catch (IOException e) { + log.error("이미지 리사이징 에러", e); + throw new BadRequestException(S3_RESIZE_ERROR); + } + } + + private NewImage toMultipartFile(byte[] bytes) { + return NewImage.of(fileName, image.getContentType(), bytes); + } +} diff --git a/src/main/java/com/moabam/api/domain/image/ImageSize.java b/src/main/java/com/moabam/api/domain/image/ImageSize.java new file mode 100644 index 00000000..9f311251 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/image/ImageSize.java @@ -0,0 +1,17 @@ +package com.moabam.api.domain.image; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ImageSize { + + CAGE(450), + BIRD_SKIN(150), + COUPON_EVENT(420), + PROFILE_IMAGE(150), + CERTIFICATION_IMAGE(220); + + private final int width; +} diff --git a/src/main/java/com/moabam/api/domain/image/ImageType.java b/src/main/java/com/moabam/api/domain/image/ImageType.java new file mode 100644 index 00000000..dc4dc358 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/image/ImageType.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.image; + +public enum ImageType { + + PROFILE_IMAGE, + CERTIFICATION, + BIRD_SKIN, + DEFAULT +} diff --git a/src/main/java/com/moabam/api/domain/image/NewImage.java b/src/main/java/com/moabam/api/domain/image/NewImage.java new file mode 100644 index 00000000..6367d63c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/image/NewImage.java @@ -0,0 +1,67 @@ +package com.moabam.api.domain.image; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class NewImage implements MultipartFile { + + private final String name; + private final String contentType; + private final long size; + private final byte[] bytes; + + public static NewImage of(String name, String contentType, byte[] bytes) { + return new NewImage(name, contentType, bytes.length, bytes); + } + + @Override + public String getName() { + return name; + } + + @Override + public String getOriginalFilename() { + return name; + } + + @Override + public String getContentType() { + return contentType; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return size; + } + + @Override + public byte[] getBytes() throws IOException { + return bytes; + } + + @Override + public InputStream getInputStream() throws IOException { + return new ByteArrayInputStream(bytes); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + try (FileOutputStream fileOutputStream = new FileOutputStream(dest)) { + fileOutputStream.write(this.getBytes()); + } + } +} diff --git a/src/main/java/com/moabam/api/domain/item/Inventory.java b/src/main/java/com/moabam/api/domain/item/Inventory.java new file mode 100644 index 00000000..44c6ab26 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/Inventory.java @@ -0,0 +1,66 @@ +package com.moabam.api.domain.item; + +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.member.Member; +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "inventory", indexes = @Index(name = "idx_member_id", columnList = "member_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Inventory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "item_id", updatable = false, nullable = false) + private Item item; + + @Column(name = "is_default", nullable = false) + @ColumnDefault("false") + private boolean isDefault; + + @Builder + private Inventory(Long memberId, Item item, boolean isDefault) { + this.memberId = requireNonNull(memberId); + this.item = requireNonNull(item); + this.isDefault = isDefault; + } + + public ItemType getItemType() { + return this.item.getType(); + } + + public void select(Member member) { + this.isDefault = true; + member.changeDefaultSkintUrl(this.item); + } + + public void deselect() { + this.isDefault = false; + } +} diff --git a/src/main/java/com/moabam/api/domain/item/Item.java b/src/main/java/com/moabam/api/domain/item/Item.java new file mode 100644 index 00000000..6fefd03e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/Item.java @@ -0,0 +1,119 @@ +package com.moabam.api.domain.item; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.bug.BugType; +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "item") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Item extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(value = EnumType.STRING) + @Column(name = "type", nullable = false) + private ItemType type; + + @Enumerated(value = EnumType.STRING) + @Column(name = "category", nullable = false) + private ItemCategory category; + + @Column(name = "name", nullable = false) + private String name; + + @Embedded + private ItemImage image; + + @Column(name = "bug_price", nullable = false) + @ColumnDefault("0") + private int bugPrice; + + @Column(name = "golden_bug_price", nullable = false) + @ColumnDefault("0") + private int goldenBugPrice; + + @Column(name = "unlock_level", nullable = false) + @ColumnDefault("1") + private int unlockLevel; + + @Builder + private Item(ItemType type, ItemCategory category, String name, ItemImage image, int bugPrice, int goldenBugPrice, + Integer unlockLevel) { + this.type = requireNonNull(type); + this.category = requireNonNull(category); + this.name = requireNonNull(name); + this.image = requireNonNull(image); + this.bugPrice = validatePrice(bugPrice); + this.goldenBugPrice = validatePrice(goldenBugPrice); + this.unlockLevel = validateLevel(requireNonNullElse(unlockLevel, 1)); + } + + private int validatePrice(int price) { + if (price < 0) { + throw new BadRequestException(INVALID_PRICE); + } + + return price; + } + + private int validateLevel(int level) { + if (level < 1) { + throw new BadRequestException(INVALID_LEVEL); + } + + return level; + } + + public void validatePurchasable(BugType bugType, int memberLevel) { + validateUnlocked(memberLevel); + validateBugTypeMatch(bugType); + } + + private void validateUnlocked(int memberLevel) { + if (this.unlockLevel > memberLevel) { + throw new BadRequestException(ITEM_UNLOCK_LEVEL_HIGH); + } + } + + private void validateBugTypeMatch(BugType bugType) { + if (!this.type.isPurchasableBy(bugType)) { + throw new BadRequestException(ITEM_NOT_PURCHASABLE_BY_BUG_TYPE); + } + } + + public int getPrice(BugType bugType) { + return bugType.isGoldenBug() ? this.goldenBugPrice : this.bugPrice; + } + + public String getAwakeImage() { + return this.getImage().getAwake(); + } + + public String getSleepImage() { + return this.getImage().getSleep(); + } +} diff --git a/src/main/java/com/moabam/api/domain/item/ItemCategory.java b/src/main/java/com/moabam/api/domain/item/ItemCategory.java new file mode 100644 index 00000000..5690d5f5 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/ItemCategory.java @@ -0,0 +1,6 @@ +package com.moabam.api.domain.item; + +public enum ItemCategory { + + SKIN; +} diff --git a/src/main/java/com/moabam/api/domain/item/ItemImage.java b/src/main/java/com/moabam/api/domain/item/ItemImage.java new file mode 100644 index 00000000..937a55b1 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/ItemImage.java @@ -0,0 +1,28 @@ +package com.moabam.api.domain.item; + +import static java.util.Objects.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ItemImage { + + @Column(name = "awake_image", nullable = false) + private String awake; + + @Column(name = "sleep_image", nullable = false) + private String sleep; + + @Builder + public ItemImage(String awakeImage, String sleepImage) { + this.awake = requireNonNull(awakeImage); + this.sleep = requireNonNull(sleepImage); + } +} diff --git a/src/main/java/com/moabam/api/domain/item/ItemType.java b/src/main/java/com/moabam/api/domain/item/ItemType.java new file mode 100644 index 00000000..4297d567 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/ItemType.java @@ -0,0 +1,21 @@ +package com.moabam.api.domain.item; + +import java.util.List; + +import com.moabam.api.domain.bug.BugType; + +public enum ItemType { + + MORNING(List.of(BugType.MORNING, BugType.GOLDEN)), + NIGHT(List.of(BugType.NIGHT, BugType.GOLDEN)); + + private final List purchasableBugTypes; + + ItemType(List purchasableBugTypes) { + this.purchasableBugTypes = purchasableBugTypes; + } + + public boolean isPurchasableBy(BugType bugType) { + return this.purchasableBugTypes.contains(bugType); + } +} diff --git a/src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java new file mode 100644 index 00000000..73a044f3 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.item.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.item.Inventory; + +public interface InventoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java new file mode 100644 index 00000000..0141d2e4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java @@ -0,0 +1,77 @@ +package com.moabam.api.domain.item.repository; + +import static com.moabam.api.domain.item.QInventory.*; +import static com.moabam.api.domain.item.QItem.*; + +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class InventorySearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findOne(Long memberId, Long itemId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(inventory) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(itemId, inventory.item.id::eq)) + .fetchOne() + ); + } + + public Optional findDefault(Long memberId, ItemType type) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(inventory) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(type, inventory.item.type::eq), + inventory.isDefault.isTrue()) + .fetchOne() + ); + } + + public List findItems(Long memberId, ItemType type) { + return jpaQueryFactory.selectFrom(inventory) + .join(inventory.item, item) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(type, inventory.item.type::eq)) + .orderBy(inventory.createdAt.desc()) + .select(item) + .fetch(); + } + + public List findDefaultSkin(Long memberId) { + return jpaQueryFactory.selectFrom(inventory) + .join(inventory.item) + .on(inventory.item.id.eq(item.id)) + .where( + inventory.memberId.eq(memberId), + inventory.isDefault.isTrue() + ).fetch(); + } + + public List findDefaultInventories(List memberId, String roomType) { + return jpaQueryFactory.selectFrom(inventory) + .join(inventory.item, item).fetchJoin() + .where( + inventory.memberId.in(memberId), + inventory.isDefault.isTrue(), + inventory.item.type.eq(ItemType.valueOf(roomType)) + ) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java b/src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java new file mode 100644 index 00000000..dd5554b8 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.item.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.item.Item; + +public interface ItemRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java new file mode 100644 index 00000000..14a62b7e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java @@ -0,0 +1,39 @@ +package com.moabam.api.domain.item.repository; + +import static com.moabam.api.domain.item.QInventory.inventory; +import static com.moabam.api.domain.item.QItem.item; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ItemSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findNotPurchasedItems(Long memberId, ItemType type) { + return jpaQueryFactory.selectFrom(item) + .leftJoin(inventory) + .on(inventory.item.id.eq(item.id) + .and(inventory.memberId.eq(memberId))) + .where( + DynamicQuery.generateEq(type, item.type::eq), + inventory.memberId.isNull() + ) + .orderBy( + item.unlockLevel.asc(), + item.bugPrice.asc(), + item.goldenBugPrice.asc(), + item.name.asc()) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/member/Badge.java b/src/main/java/com/moabam/api/domain/member/Badge.java new file mode 100644 index 00000000..491a8ebf --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/Badge.java @@ -0,0 +1,48 @@ +package com.moabam.api.domain.member; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Badge { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @Enumerated(EnumType.STRING) + @Column(name = "type", nullable = false) + private BadgeType type; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Builder + private Badge(Long memberId, BadgeType type) { + this.memberId = requireNonNull(memberId); + this.type = requireNonNull(type); + } +} diff --git a/src/main/java/com/moabam/api/domain/member/BadgeType.java b/src/main/java/com/moabam/api/domain/member/BadgeType.java new file mode 100644 index 00000000..e0819f9a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/BadgeType.java @@ -0,0 +1,41 @@ +package com.moabam.api.domain.member; + +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import com.moabam.api.dto.member.BadgeResponse; + +import lombok.Getter; + +@Getter +public enum BadgeType { + + BIRTH(10, "탄생 축하 뱃지"), + LEVEL10(100, "10레벨 뱃지"), + LEVEL50(500, "50레벨 뱃지"); + + private final long certifyCount; + private final String korean; + + BadgeType(long certifyCount, String korean) { + this.certifyCount = certifyCount; + this.korean = korean; + } + + public static List memberBadgeMap(Set badgeTypes) { + return Arrays.stream(BadgeType.values()) + .map(badgeType -> BadgeResponse.builder() + .badge(badgeType.korean) + .unlock(badgeTypes.contains(badgeType)) + .build()) + .toList(); + } + + public static Optional getBadgeFrom(long certifyCount) { + return Arrays.stream(BadgeType.values()) + .filter(badgeType -> badgeType.certifyCount == certifyCount) + .findFirst(); + } +} diff --git a/src/main/java/com/moabam/api/domain/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java new file mode 100644 index 00000000..51e8d9c3 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -0,0 +1,178 @@ +package com.moabam.api.domain.member; + +import static com.moabam.global.common.util.BaseImageUrl.*; +import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.common.util.RandomUtils.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.time.LocalDateTime; +import java.util.Objects; + +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.SQLDelete; + +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.room.RoomType; +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.NotFoundException; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "member") +@SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Member extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "social_id", nullable = false, unique = true) + private String socialId; + + @Column(name = "nickname", unique = true) + private String nickname; + + @Column(name = "intro", length = 30) + private String intro; + + @Column(name = "profile_image", nullable = false) + private String profileImage; + + @Column(name = "morning_image", nullable = false) + private String morningImage; + + @Column(name = "night_image", nullable = false) + private String nightImage; + + @Column(name = "total_certify_count", nullable = false) + @ColumnDefault("0") + private long totalCertifyCount; + + @Column(name = "report_count", nullable = false) + @ColumnDefault("0") + private int reportCount; + + @Column(name = "current_night_count", nullable = false) + @ColumnDefault("0") + private int currentNightCount; + + @Column(name = "current_morning_count", nullable = false) + @ColumnDefault("0") + private int currentMorningCount; + + @Embedded + private Bug bug; + + @Enumerated(EnumType.STRING) + @Column(name = "role", nullable = false) + @ColumnDefault("'USER'") + private Role role; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private Member(Long id, String socialId, Bug bug) { + this.id = id; + this.socialId = requireNonNull(socialId); + this.nickname = createNickName(); + this.intro = ""; + this.profileImage = IMAGE_DOMAIN + MEMBER_PROFILE_URL; + this.morningImage = IMAGE_DOMAIN + DEFAULT_MORNING_EGG_URL; + this.nightImage = IMAGE_DOMAIN + DEFAULT_NIGHT_EGG_URL; + this.bug = requireNonNull(bug); + this.role = Role.USER; + } + + public void enterRoom(RoomType roomType) { + if (roomType.equals(RoomType.MORNING)) { + this.currentMorningCount++; + return; + } + + if (roomType.equals(RoomType.NIGHT)) { + this.currentNightCount++; + } + } + + public void exitRoom(RoomType roomType) { + if (roomType.equals(RoomType.MORNING) && currentMorningCount > 0) { + this.currentMorningCount--; + return; + } + + if (roomType.equals(RoomType.NIGHT) && currentNightCount > 0) { + this.currentNightCount--; + } + } + + public int getLevel() { + return (int)(totalCertifyCount / LEVEL_DIVISOR) + 1; + } + + public void increaseTotalCertifyCount() { + this.totalCertifyCount++; + } + + public void delete(LocalDateTime now) { + socialId = deleteSocialId(now); + nickname = null; + } + + public boolean changeNickName(String nickname) { + if (Objects.isNull(nickname)) { + return false; + } + this.nickname = nickname; + return true; + } + + public void changeIntro(String intro) { + this.intro = requireNonNullElse(intro, this.intro); + } + + public void changeProfileUri(String newProfileUri) { + this.profileImage = requireNonNullElse(newProfileUri, profileImage); + } + + public void changeDefaultSkintUrl(Item item) throws NotFoundException { + if (ItemType.MORNING.equals(item.getType())) { + this.morningImage = item.getAwakeImage(); + return; + } + + if (ItemType.NIGHT.equals(item.getType())) { + this.nightImage = item.getAwakeImage(); + return; + } + + throw new NotFoundException(SKIN_TYPE_NOT_FOUND); + } + + private String createNickName() { + return "오목눈이#" + randomStringValues(); + } + + private String deleteSocialId(LocalDateTime now) { + return "delete_" + now.toString() + randomNumberValues(); + } +} diff --git a/src/main/java/com/moabam/api/domain/member/Role.java b/src/main/java/com/moabam/api/domain/member/Role.java new file mode 100644 index 00000000..b7e80810 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/Role.java @@ -0,0 +1,8 @@ +package com.moabam.api.domain.member; + +public enum Role { + + USER, + BLACK, + ADMIN +} diff --git a/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java b/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java new file mode 100644 index 00000000..ac313e25 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.member.Badge; +import com.moabam.api.domain.member.BadgeType; + +public interface BadgeRepository extends JpaRepository { + + boolean existsByMemberIdAndType(Long memberId, BadgeType type); + +} diff --git a/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java b/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java new file mode 100644 index 00000000..f0cb499b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java @@ -0,0 +1,14 @@ +package com.moabam.api.domain.member.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.member.Member; + +public interface MemberRepository extends JpaRepository { + + Optional findBySocialId(String id); + + boolean existsByNickname(String nickname); +} diff --git a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java new file mode 100644 index 00000000..6285f9bb --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -0,0 +1,88 @@ +package com.moabam.api.domain.member.repository; + +import static com.moabam.api.domain.member.QBadge.*; +import static com.moabam.api.domain.member.QMember.*; +import static com.moabam.api.domain.room.QParticipant.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.dto.member.MemberInfo; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.core.types.Expression; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MemberSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findMember(Long memberId) { + return findMember(memberId, true); + } + + public List findAllMembers() { + return jpaQueryFactory + .selectFrom(member) + .where( + member.deletedAt.isNull() + ) + .fetch(); + } + + public Optional findMember(Long memberId, boolean isNotDeleted) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(member) + .where( + DynamicQuery.generateIsNull(isNotDeleted, member.deletedAt), + member.id.eq(memberId) + ) + .fetchOne()); + } + + public List findParticipantByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.memberId.eq(memberId), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findMemberAndBadges(Long searchId, boolean isMe) { + List> selectExpression = new ArrayList<>(List.of( + member.nickname, + member.profileImage, + member.morningImage, + member.nightImage, + member.intro, + member.totalCertifyCount, + badge.type)); + + if (isMe) { + selectExpression.addAll(List.of( + member.bug.goldenBug, + member.bug.morningBug, + member.bug.nightBug)); + } + + return jpaQueryFactory + .select(Projections.constructor(MemberInfo.class, selectExpression.toArray(new Expression[0]))) + .from(member) + .leftJoin(badge).on(member.id.eq(badge.memberId)) + .where( + DynamicQuery.generateIsNull(true, member.deletedAt), + member.id.eq(searchId) + ).fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java new file mode 100644 index 00000000..ce856cc8 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java @@ -0,0 +1,42 @@ +package com.moabam.api.domain.notification.repository; + +import static com.moabam.global.common.util.GlobalConstant.*; +import static java.util.Objects.*; + +import java.time.Duration; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NotificationRepository { + + private static final String KNOCK_KEY = "roomId=%s_targetId=%s_memberId=%s"; + private static final long EXPIRE_KNOCK = 12; + + private final ValueRedisRepository valueRedisRepository; + + public void saveKnock(Long roomId, Long targetId, Long memberId) { + String knockKey = String.format( + KNOCK_KEY, + requireNonNull(roomId), + requireNonNull(targetId), + requireNonNull(memberId)); + + valueRedisRepository.save(knockKey, BLANK, Duration.ofHours(EXPIRE_KNOCK)); + } + + public boolean existsKnockByKey(Long roomId, Long targetId, Long memberId) { + String knockKey = String.format( + KNOCK_KEY, + requireNonNull(roomId), + requireNonNull(targetId), + requireNonNull(memberId)); + + return valueRedisRepository.hasKey(requireNonNull(knockKey)); + } +} diff --git a/src/main/java/com/moabam/api/domain/payment/Order.java b/src/main/java/com/moabam/api/domain/payment/Order.java new file mode 100644 index 00000000..c907c46d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/Order.java @@ -0,0 +1,31 @@ +package com.moabam.api.domain.payment; + +import static java.util.Objects.*; + +import jakarta.persistence.Column; +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Embeddable +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Order { + + @Column(name = "order_id") + private String id; + + @Column(name = "order_name", nullable = false) + private String name; + + @Builder + private Order(String name) { + this.name = requireNonNull(name); + } + + public void updateId(String id) { + this.id = id; + } +} diff --git a/src/main/java/com/moabam/api/domain/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java new file mode 100644 index 00000000..db81105b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -0,0 +1,146 @@ +package com.moabam.api.domain.payment; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.product.Product; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "payment", indexes = @Index(name = "idx_order_id", columnList = "order_id")) +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class Payment { + + private static final int MIN_AMOUNT = 0; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", updatable = false, nullable = false) + private Product product; + + @Column(name = "coupon_wallet_id") + private Long couponWalletId; + + @Embedded + private Order order; + + @Column(name = "total_amount", nullable = false) + private int totalAmount; + + @Column(name = "discount_amount", nullable = false) + private int discountAmount; + + @Column(name = "payment_key") + private String paymentKey; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @Column(name = "requested_at") + private LocalDateTime requestedAt; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Builder + public Payment(Long memberId, Product product, Long couponWalletId, Order order, int totalAmount, + int discountAmount, PaymentStatus status) { + this.memberId = requireNonNull(memberId); + this.product = requireNonNull(product); + this.couponWalletId = couponWalletId; + this.order = requireNonNull(order); + this.totalAmount = validateAmount(totalAmount); + this.discountAmount = validateAmount(discountAmount); + this.status = requireNonNullElse(status, PaymentStatus.READY); + } + + private int validateAmount(int amount) { + if (amount < MIN_AMOUNT) { + throw new BadRequestException(INVALID_PAYMENT_AMOUNT); + } + + return amount; + } + + public void validateInfo(Long memberId, int amount) { + validateByMember(memberId); + validateByTotalAmount(amount); + } + + public void validateByMember(Long memberId) { + if (!this.memberId.equals(memberId)) { + throw new BadRequestException(INVALID_MEMBER_PAYMENT); + } + } + + private void validateByTotalAmount(int amount) { + if (this.totalAmount != amount) { + throw new BadRequestException(INVALID_PAYMENT_INFO); + } + } + + public boolean isCouponApplied() { + return !isNull(this.couponWalletId); + } + + public void applyCoupon(CouponWallet couponWallet) { + this.couponWalletId = couponWallet.getId(); + this.discountAmount = couponWallet.getCoupon().getPoint(); + this.totalAmount = Math.max(MIN_AMOUNT, this.totalAmount - this.discountAmount); + } + + public void request(String orderId) { + this.order.updateId(orderId); + this.requestedAt = LocalDateTime.now(); + } + + public void confirm(String paymentKey) { + this.paymentKey = paymentKey; + this.approvedAt = LocalDateTime.now(); + this.status = PaymentStatus.DONE; + } + + public void fail(String paymentKey) { + this.paymentKey = paymentKey; + this.status = PaymentStatus.ABORTED; + } +} diff --git a/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java new file mode 100644 index 00000000..dae76ff6 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java @@ -0,0 +1,19 @@ +package com.moabam.api.domain.payment; + +/** + * READY: 결제 생성 + * IN_PROGRESS: 결제 인증 완료 + * DONE: 결제 승인 완료 + * CANCELED: 승인된 결제 취소 + * ABORTED: 결제 승인 실패 + * EXPIRED: 유효 시간 경과로 거래 취소 + */ +public enum PaymentStatus { + + READY, + IN_PROGRESS, + DONE, + CANCELED, + ABORTED, + EXPIRED; +} diff --git a/src/main/java/com/moabam/api/domain/payment/repository/PaymentRepository.java b/src/main/java/com/moabam/api/domain/payment/repository/PaymentRepository.java new file mode 100644 index 00000000..aca0dba9 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.payment.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.payment.Payment; + +public interface PaymentRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java b/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java new file mode 100644 index 00000000..dafb6925 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java @@ -0,0 +1,27 @@ +package com.moabam.api.domain.payment.repository; + +import static com.moabam.api.domain.payment.QPayment.*; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.payment.Payment; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class PaymentSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findByOrderId(String orderId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(payment) + .where(payment.order.id.eq(orderId)) + .fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/domain/product/Product.java b/src/main/java/com/moabam/api/domain/product/Product.java new file mode 100644 index 00000000..d99249e1 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/product/Product.java @@ -0,0 +1,73 @@ +package com.moabam.api.domain.product; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "product") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Product extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(value = EnumType.STRING) + @Column(name = "type", nullable = false) + @ColumnDefault("'BUG'") + private ProductType type; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private int price; + + @Column(name = "quantity", nullable = false) + @ColumnDefault("1") + private int quantity; + + @Builder + private Product(ProductType type, String name, int price, Integer quantity) { + this.type = requireNonNullElse(type, ProductType.BUG); + this.name = requireNonNull(name); + this.price = validatePrice(price); + this.quantity = validateQuantity(requireNonNullElse(quantity, 1)); + } + + private int validatePrice(int price) { + if (price < 0) { + throw new BadRequestException(INVALID_PRICE); + } + + return price; + } + + private int validateQuantity(int quantity) { + if (quantity < 1) { + throw new BadRequestException(INVALID_QUANTITY); + } + + return quantity; + } +} diff --git a/src/main/java/com/moabam/api/domain/product/ProductType.java b/src/main/java/com/moabam/api/domain/product/ProductType.java new file mode 100644 index 00000000..1dc5c46b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/product/ProductType.java @@ -0,0 +1,6 @@ +package com.moabam.api.domain.product; + +public enum ProductType { + + BUG; +} diff --git a/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java b/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java new file mode 100644 index 00000000..358aa0c0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java @@ -0,0 +1,13 @@ +package com.moabam.api.domain.product.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.ProductType; + +public interface ProductRepository extends JpaRepository { + + List findAllByType(ProductType type); +} diff --git a/src/main/java/com/moabam/api/domain/report/Report.java b/src/main/java/com/moabam/api/domain/report/Report.java new file mode 100644 index 00000000..4e120055 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/report/Report.java @@ -0,0 +1,56 @@ +package com.moabam.api.domain.report; + +import static java.util.Objects.*; + +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "report") +@Entity +public class Report extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "reporter_id", nullable = false, updatable = false) + private Long reporterId; + + @Column(name = "reported_member_id", nullable = false, updatable = false) + private Long reportedMemberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", updatable = false) + private Room room; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "certification_id", updatable = false) + private Certification certification; + + @Column(name = "description") + private String description; + + @Builder + private Report(Long reporterId, Long reportedMemberId, Room room, Certification certification, String description) { + this.reporterId = requireNonNull(reporterId); + this.reportedMemberId = requireNonNull(reportedMemberId); + this.room = room; + this.certification = certification; + this.description = description; + } +} diff --git a/src/main/java/com/moabam/api/domain/report/repository/ReportRepository.java b/src/main/java/com/moabam/api/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..1655fbbc --- /dev/null +++ b/src/main/java/com/moabam/api/domain/report/repository/ReportRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.report.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.report.Report; + +public interface ReportRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/room/Certification.java b/src/main/java/com/moabam/api/domain/room/Certification.java new file mode 100644 index 00000000..c2ce121b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/Certification.java @@ -0,0 +1,53 @@ +package com.moabam.api.domain.room; + +import static java.util.Objects.*; + +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "certification") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Certification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "routine_id", nullable = false, updatable = false) + private Routine routine; + + @Column(name = "member_id", nullable = false, updatable = false) + private Long memberId; + + @Column(name = "image", nullable = false) + private String image; + + @Builder + private Certification(Long id, Routine routine, Long memberId, String image) { + this.id = id; + this.routine = requireNonNull(routine); + this.memberId = requireNonNull(memberId); + this.image = requireNonNull(image); + } + + public void changeImage(String image) { + this.image = image; + } +} diff --git a/src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java b/src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java new file mode 100644 index 00000000..afe64cd2 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java @@ -0,0 +1,49 @@ +package com.moabam.api.domain.room; + +import static java.util.Objects.*; + +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "daily_member_certification") // 매일 사용자가 방에 인증을 완료했는지 -> createdAt으로 인증 시각 확인 +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyMemberCertification extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "member_id", nullable = false, updatable = false) + private Long memberId; + + @Column(name = "room_id", nullable = false, updatable = false) + private Long roomId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "participant_id") // Participant createdAt으로 방 참여 시작 날짜 확인, certifyCount 가져다가 쓰기 + private Participant participant; + + @Builder + private DailyMemberCertification(Long id, Long memberId, Long roomId, Participant participant) { + this.id = id; + this.memberId = requireNonNull(memberId); + this.roomId = requireNonNull(roomId); + this.participant = requireNonNull(participant); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java b/src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java new file mode 100644 index 00000000..573261ba --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java @@ -0,0 +1,41 @@ +package com.moabam.api.domain.room; + +import static java.util.Objects.*; + +import java.time.LocalDate; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "daily_room_certification") // 매일 방이 인증을 완료했는지 -> certifiedAt으로 인증 날짜 확인 +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class DailyRoomCertification { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "room_id", nullable = false, updatable = false) + private Long roomId; + + @Column(name = "certified_at", nullable = false, updatable = false) + private LocalDate certifiedAt; + + @Builder + private DailyRoomCertification(Long id, Long roomId, LocalDate certifiedAt) { + this.id = id; + this.roomId = requireNonNull(roomId); + this.certifiedAt = requireNonNull(certifiedAt); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/Participant.java b/src/main/java/com/moabam/api/domain/room/Participant.java new file mode 100644 index 00000000..0a0d3d02 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/Participant.java @@ -0,0 +1,80 @@ +package com.moabam.api.domain.room; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.SQLDelete; + +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "participant") +@SQLDelete(sql = "UPDATE participant SET deleted_at = CURRENT_TIMESTAMP where id = ?") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Participant extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id") + private Room room; + + @Column(name = "member_id", updatable = false, nullable = false) + private Long memberId; + + @Column(name = "is_manager") + private boolean isManager; + + @Column(name = "certify_count") + private int certifyCount; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "deleted_room_title", length = 30) + private String deletedRoomTitle; + + @Builder + private Participant(Long id, Room room, Long memberId) { + this.id = id; + this.room = requireNonNull(room); + this.memberId = requireNonNull(memberId); + this.isManager = false; + this.certifyCount = 0; + } + + public void disableManager() { + this.isManager = false; + } + + public void enableManager() { + this.isManager = true; + } + + public void updateCertifyCount() { + this.certifyCount += 1; + } + + public void removeRoom() { + this.deletedRoomTitle = this.room.getTitle(); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java new file mode 100644 index 00000000..6817cb4d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/Room.java @@ -0,0 +1,196 @@ +package com.moabam.api.domain.room; + +import static com.moabam.api.domain.room.RoomType.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.SQLDelete; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "room") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE room SET deleted_at = CURRENT_TIMESTAMP where id = ?") +public class Room extends BaseTimeEntity { + + private static final int LEVEL_0 = 0; + private static final int LEVEL_1 = 1; + private static final int LEVEL_2 = 2; + private static final int LEVEL_3 = 3; + private static final int LEVEL_4 = 4; + private static final int LEVEL_5 = 5; + private static final String ROOM_LEVEL_0_IMAGE = "https://image.moabam.com/moabam/default/room-level-00.png"; + private static final String ROOM_LEVEL_1_IMAGE = "https://image.moabam.com/moabam/default/room-level-01.png"; + private static final String ROOM_LEVEL_2_IMAGE = "https://image.moabam.com/moabam/default/room-level-02.png"; + private static final String ROOM_LEVEL_3_IMAGE = "https://image.moabam.com/moabam/default/room-level-03.png"; + private static final String ROOM_LEVEL_4_IMAGE = "https://image.moabam.com/moabam/default/room-level-04.png"; + private static final String ROOM_LEVEL_5_IMAGE = "https://image.moabam.com/moabam/default/room-level-05.png"; + private static final int MORNING_START_TIME = 4; + private static final int MORNING_END_TIME = 10; + private static final int NIGHT_START_TIME = 20; + private static final int NIGHT_END_TIME = 2; + private static final int CLOCK_ZERO = 0; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "title", nullable = false, length = 20) + private String title; + + @Column(name = "password", length = 8) + private String password; + + @ColumnDefault("0") + @Column(name = "level", nullable = false) + private int level; + + @ColumnDefault("0") + @Column(name = "exp", nullable = false) + private int exp; + + @Enumerated(value = EnumType.STRING) + @Column(name = "room_type") + private RoomType roomType; + + @Column(name = "certify_time", nullable = false) + private int certifyTime; + + @Column(name = "current_user_count", nullable = false) + private int currentUserCount; + + @Column(name = "max_user_count", nullable = false) + private int maxUserCount; + + @Column(name = "announcement", length = 100) + private String announcement; + + @ColumnDefault("'" + ROOM_LEVEL_0_IMAGE + "'") + @Column(name = "room_image", length = 500) + private String roomImage; + + @Column(name = "manager_nickname", length = 30) + private String managerNickname; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Builder + private Room(Long id, String title, String password, RoomType roomType, int certifyTime, int maxUserCount) { + this.id = id; + this.title = requireNonNull(title); + this.password = password; + this.level = 0; + this.exp = 0; + this.roomType = requireNonNull(roomType); + this.certifyTime = validateCertifyTime(roomType, certifyTime); + this.currentUserCount = 1; + this.maxUserCount = maxUserCount; + this.roomImage = ROOM_LEVEL_0_IMAGE; + } + + public void levelUp() { + this.level += 1; + this.exp = 0; + upgradeRoomImage(this.level); + } + + public void upgradeRoomImage(int level) { + if (level == LEVEL_1) { + this.roomImage = ROOM_LEVEL_1_IMAGE; + return; + } + + if (level == LEVEL_2) { + this.roomImage = ROOM_LEVEL_2_IMAGE; + return; + } + + if (level == LEVEL_3) { + this.roomImage = ROOM_LEVEL_3_IMAGE; + return; + } + + if (level == LEVEL_4) { + this.roomImage = ROOM_LEVEL_4_IMAGE; + return; + } + + if (level == LEVEL_5) { + this.roomImage = ROOM_LEVEL_5_IMAGE; + } + } + + public void gainExp() { + this.exp += 1; + } + + public void changeAnnouncement(String announcement) { + this.announcement = announcement; + } + + public void changeTitle(String title) { + this.title = title; + } + + public void changePassword(String password) { + this.password = password; + } + + public void changeManagerNickname(String managerNickname) { + this.managerNickname = managerNickname; + } + + public void changeMaxCount(int maxUserCount) { + if (maxUserCount < this.currentUserCount) { + throw new BadRequestException(ROOM_MAX_USER_COUNT_MODIFY_FAIL); + } + + this.maxUserCount = maxUserCount; + } + + public void increaseCurrentUserCount() { + this.currentUserCount += 1; + } + + public void decreaseCurrentUserCount() { + this.currentUserCount -= 1; + } + + public void changeCertifyTime(int certifyTime) { + this.certifyTime = validateCertifyTime(this.roomType, certifyTime); + } + + private int validateCertifyTime(RoomType roomType, int certifyTime) { + if (roomType.equals(MORNING) && (certifyTime < MORNING_START_TIME || certifyTime > MORNING_END_TIME)) { + throw new BadRequestException(INVALID_REQUEST_FIELD); + } + + if (roomType.equals(NIGHT) + && ((certifyTime < NIGHT_START_TIME && certifyTime > NIGHT_END_TIME) || certifyTime < CLOCK_ZERO)) { + throw new BadRequestException(INVALID_REQUEST_FIELD); + } + + return certifyTime; + } +} diff --git a/src/main/java/com/moabam/api/domain/room/RoomExp.java b/src/main/java/com/moabam/api/domain/room/RoomExp.java new file mode 100644 index 00000000..409b91fb --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/RoomExp.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.room; + +import java.util.Collections; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * 방 경험치 + * 방 레벨 - 현재 경험치 / 전체 경험치 + * 레벨0 - 0 / 1 + * 레벨1 - 0 / 3 + * 레벨2 - 0 / 5 + * 레벨3 - 0 / 10 + */ + +@Getter +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public enum RoomExp { + + ROOM_LEVEL_0(0, 1), + ROOM_LEVEL_1(1, 5), + ROOM_LEVEL_2(2, 10), + ROOM_LEVEL_3(3, 20), + ROOM_LEVEL_4(4, 40), + ROOM_LEVEL_5(5, 80); + + private static final Map requireExpMap = Collections.unmodifiableMap( + Stream.of(values()) + .collect(Collectors.toMap(RoomExp::getLevel, RoomExp::name)) + ); + + private final int level; + private final int totalExp; + + public static RoomExp of(int level) { + return RoomExp.valueOf(requireExpMap.get(level)); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/RoomType.java b/src/main/java/com/moabam/api/domain/room/RoomType.java new file mode 100644 index 00000000..fd63618b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/RoomType.java @@ -0,0 +1,7 @@ +package com.moabam.api.domain.room; + +public enum RoomType { + + MORNING, + NIGHT +} diff --git a/src/main/java/com/moabam/api/domain/room/Routine.java b/src/main/java/com/moabam/api/domain/room/Routine.java new file mode 100644 index 00000000..6b3f0a86 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/Routine.java @@ -0,0 +1,61 @@ +package com.moabam.api.domain.room; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.apache.commons.lang3.StringUtils; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "routine") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Routine extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", updatable = false) + private Room room; + + @Column(name = "content", nullable = false, length = 20) + private String content; + + @Builder + private Routine(Long id, Room room, String content) { + this.id = id; + this.room = requireNonNull(room); + this.content = validateContent(content); + } + + public void changeContent(String content) { + this.content = content; + } + + private String validateContent(String content) { + if (StringUtils.isBlank(content) || content.length() > 20) { + throw new BadRequestException(ROUTINE_LENGTH_ERROR); + } + + return content; + } +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java new file mode 100644 index 00000000..c8389591 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.room.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.room.Certification; + +public interface CertificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java new file mode 100644 index 00000000..8563245c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java @@ -0,0 +1,84 @@ +package com.moabam.api.domain.room.repository; + +import static com.moabam.api.domain.room.QCertification.*; +import static com.moabam.api.domain.room.QDailyMemberCertification.*; +import static com.moabam.api.domain.room.QDailyRoomCertification.*; +import static com.moabam.api.domain.room.QParticipant.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CertificationsSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findCertifications(Long roomId, LocalDate date) { + return jpaQueryFactory.selectFrom(certification) + .where( + certification.routine.room.id.eq(roomId), + certification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) + ) + .fetch(); + } + + public Optional findDailyMemberCertification(Long memberId, Long roomId, LocalDate date) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(dailyMemberCertification) + .where( + dailyMemberCertification.memberId.eq(memberId), + dailyMemberCertification.roomId.eq(roomId), + dailyMemberCertification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) + ) + .fetchOne() + ); + } + + public List findSortedDailyMemberCertifications(Long roomId, LocalDate date) { + return jpaQueryFactory + .selectFrom(dailyMemberCertification) + .join(dailyMemberCertification.participant, participant).fetchJoin() + .where( + dailyMemberCertification.roomId.eq(roomId), + dailyMemberCertification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) + ) + .orderBy( + dailyMemberCertification.createdAt.asc() + ) + .fetch(); + } + + public Optional findDailyRoomCertification(Long roomId, LocalDate date) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(dailyRoomCertification) + .where( + dailyRoomCertification.roomId.eq(roomId), + dailyRoomCertification.certifiedAt.eq(date) + ) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetchOne()); + } + + public List findDailyRoomCertifications(Long roomId, LocalDate today) { + return jpaQueryFactory + .selectFrom(dailyRoomCertification) + .where( + dailyRoomCertification.roomId.eq(roomId), + dailyRoomCertification.certifiedAt.between(today.minusWeeks(1), today) + ) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java new file mode 100644 index 00000000..ec267010 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java @@ -0,0 +1,15 @@ +package com.moabam.api.domain.room.repository; + +import java.time.LocalDateTime; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.room.DailyMemberCertification; + +public interface DailyMemberCertificationRepository extends JpaRepository { + + boolean existsByMemberIdAndRoomIdAndCreatedAtBetween(Long memberId, Long roomId, LocalDateTime startTime, + LocalDateTime endTime); + + boolean existsByRoomIdAndCreatedAtBetween(Long roomId, LocalDateTime startTime, LocalDateTime endTime); +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java new file mode 100644 index 00000000..47194085 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.room.repository; + +import java.time.LocalDate; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.room.DailyRoomCertification; + +public interface DailyRoomCertificationRepository extends JpaRepository { + + boolean existsByRoomIdAndCertifiedAt(Long roomId, LocalDate date); +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java b/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java new file mode 100644 index 00000000..875e6a03 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.room.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.room.Participant; + +public interface ParticipantRepository extends JpaRepository { + + List findAllByMemberId(Long id); +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java new file mode 100644 index 00000000..f391e4f0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -0,0 +1,121 @@ +package com.moabam.api.domain.room.repository; + +import static com.moabam.api.domain.room.QParticipant.*; +import static com.moabam.api.domain.room.QRoom.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.room.Participant; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ParticipantSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findOne(Long memberId, Long roomId) { + return Optional.ofNullable( + jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where( + DynamicQuery.generateEq(roomId, participant.room.id::eq), + DynamicQuery.generateEq(memberId, participant.memberId::eq), + participant.deletedAt.isNull() + ) + .fetchOne() + ); + } + + public List findAllByRoomId(Long roomId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findAllByMemberIdParticipant(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.memberId.eq(memberId), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findAllWithDeletedByRoomId(Long roomId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId) + ) + .fetch(); + } + + public List findAllByRoomIdBeforeDate(Long roomId, LocalDateTime date) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId), + participant.createdAt.before(date), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findNotDeletedAllByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where( + participant.memberId.eq(memberId), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findAllByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .leftJoin(participant.room, room).fetchJoin() + .where( + participant.memberId.eq(memberId) + ) + .orderBy(participant.createdAt.desc()) + .fetch(); + } + + public List findAllByRoomCertifyTime(int certifyTime) { + return jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where( + participant.room.certifyTime.eq(certifyTime), + participant.deletedAt.isNull() + ) + .fetch(); + } + + public List findAllRoomMangerByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where( + participant.memberId.eq(memberId), + participant.isManager.isTrue() + ) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java new file mode 100644 index 00000000..6994356c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java @@ -0,0 +1,57 @@ +package com.moabam.api.domain.room.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import com.moabam.api.domain.room.Room; + +import jakarta.persistence.LockModeType; + +public interface RoomRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + Optional findWithPessimisticLockByIdAndDeletedAtIsNull(Long id); + + @Query(value = "select distinct rm.* from room rm left join routine rt on rm.id = rt.room_id " + + "where (rm.title like %:keyword% " + + "or rm.manager_nickname like %:keyword% " + + "or rt.content like %:keyword%) " + + "and rm.deleted_at is null " + + "order by rm.id desc limit 11", nativeQuery = true) + List searchByKeyword(@Param(value = "keyword") String keyword); + + @Query(value = "select distinct rm.* from room rm left join routine rt on rm.id = rt.room_id " + + "where (rm.title like %:keyword% " + + "or rm.manager_nickname like %:keyword% " + + "or rt.content like %:keyword%) " + + "and rm.room_type = :roomType " + + "and rm.deleted_at is null " + + "order by rm.id desc limit 11", nativeQuery = true) + List searchByKeywordAndRoomType(@Param(value = "keyword") String keyword, + @Param(value = "roomType") String roomType); + + @Query(value = "select distinct rm.* from room rm left join routine rt on rm.id = rt.room_id " + + "where (rm.title like %:keyword% " + + "or rm.manager_nickname like %:keyword% " + + "or rt.content like %:keyword%) " + + "and rm.id < :roomId " + + "and rm.deleted_at is null " + + "order by rm.id desc limit 11", nativeQuery = true) + List searchByKeywordAndRoomId(@Param(value = "keyword") String keyword, @Param(value = "roomId") Long roomId); + + @Query(value = "select distinct rm.* from room rm left join routine rt on rm.id = rt.room_id " + + "where (rm.title like %:keyword% " + + "or rm.manager_nickname like %:keyword% " + + "or rt.content like %:keyword%) " + + "and rm.room_type = :roomType " + + "and rm.id < :roomId " + + "and rm.deleted_at is null " + + "order by rm.id desc limit 11", nativeQuery = true) + List searchByKeywordAndRoomIdAndRoomType(@Param(value = "keyword") String keyword, + @Param(value = "roomType") String roomType, @Param(value = "roomId") Long roomId); +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java new file mode 100644 index 00000000..a815760e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java @@ -0,0 +1,34 @@ +package com.moabam.api.domain.room.repository; + +import static com.moabam.api.domain.room.QRoom.*; +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RoomSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findAllWithNoOffset(RoomType roomType, Long roomId) { + return jpaQueryFactory.selectFrom(room) + .where( + DynamicQuery.generateEq(roomType, room.roomType::eq), + DynamicQuery.generateEq(roomId, room.id::lt), + room.deletedAt.isNull() + ) + .orderBy(room.id.desc()) + .limit(ROOM_FIXED_SEARCH_SIZE + 1L) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java new file mode 100644 index 00000000..add3c3be --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java @@ -0,0 +1,14 @@ +package com.moabam.api.domain.room.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.room.Routine; + +public interface RoutineRepository extends JpaRepository { + + List findAllByRoomId(Long roomId); + + List findAllByRoomIdIn(List roomIds); +} diff --git a/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java new file mode 100644 index 00000000..cadd3452 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java @@ -0,0 +1,26 @@ +package com.moabam.api.dto.auth; + +import static java.util.Objects.*; + +import java.util.List; + +import lombok.Builder; + +public record AuthorizationCodeRequest( + String clientId, + String redirectUri, + String responseType, + List scope, + String state +) { + + @Builder + public AuthorizationCodeRequest(String clientId, String redirectUri, String responseType, List scope, + String state) { + this.clientId = requireNonNull(clientId); + this.redirectUri = requireNonNull(redirectUri); + this.responseType = responseType; + this.scope = scope; + this.state = state; + } +} diff --git a/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java new file mode 100644 index 00000000..5b652696 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java @@ -0,0 +1,10 @@ +package com.moabam.api.dto.auth; + +public record AuthorizationCodeResponse( + String code, + String error, + String errorDescription, + String state +) { + +} diff --git a/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java new file mode 100644 index 00000000..8f7c5ff6 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthorizationTokenInfoResponse( + @JsonProperty("id") long id, + @JsonProperty("expires_in") String expiresIn, + @JsonProperty("app_id") String appId +) { + +} diff --git a/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java new file mode 100644 index 00000000..6254c13e --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java @@ -0,0 +1,24 @@ +package com.moabam.api.dto.auth; + +import static java.util.Objects.*; + +import lombok.Builder; + +public record AuthorizationTokenRequest( + String grantType, + String clientId, + String redirectUri, + String code, + String clientSecret +) { + + @Builder + public AuthorizationTokenRequest(String grantType, String clientId, String redirectUri, String code, + String clientSecret) { + this.grantType = requireNonNull(grantType); + this.clientId = requireNonNull(clientId); + this.redirectUri = requireNonNull(redirectUri); + this.code = requireNonNull(code); + this.clientSecret = clientSecret; + } +} diff --git a/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java new file mode 100644 index 00000000..04609d09 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto.auth; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record AuthorizationTokenResponse( + @JsonProperty("token_type") String tokenType, + @JsonProperty("access_token") String accessToken, + @JsonProperty("id_token") String idToken, + @JsonProperty("expires_in") String expiresIn, + @JsonProperty("refresh_token") String refreshToken, + @JsonProperty("refresh_token_expires_in") String refreshTokenExpiresIn, + @JsonProperty("scope") String scope +) { + +} diff --git a/src/main/java/com/moabam/api/dto/auth/LoginResponse.java b/src/main/java/com/moabam/api/dto/auth/LoginResponse.java new file mode 100644 index 00000000..8c75e8b4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/LoginResponse.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.auth; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import com.moabam.global.auth.model.PublicClaim; + +import lombok.Builder; + +@Builder +public record LoginResponse( + boolean isSignUp, + @JsonUnwrapped PublicClaim publicClaim +) { + +} diff --git a/src/main/java/com/moabam/api/dto/auth/TokenSaveValue.java b/src/main/java/com/moabam/api/dto/auth/TokenSaveValue.java new file mode 100644 index 00000000..5ac16a6c --- /dev/null +++ b/src/main/java/com/moabam/api/dto/auth/TokenSaveValue.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.auth; + +import lombok.Builder; + +@Builder +public record TokenSaveValue( + String refreshToken, + String loginIp +) { + +} diff --git a/src/main/java/com/moabam/api/dto/bug/BugHistoryItemResponse.java b/src/main/java/com/moabam/api/dto/bug/BugHistoryItemResponse.java new file mode 100644 index 00000000..8d2703ac --- /dev/null +++ b/src/main/java/com/moabam/api/dto/bug/BugHistoryItemResponse.java @@ -0,0 +1,22 @@ +package com.moabam.api.dto.bug; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.dto.payment.PaymentResponse; + +import lombok.Builder; + +@Builder +public record BugHistoryItemResponse( + Long id, + BugType bugType, + BugActionType actionType, + int quantity, + String date, + @JsonInclude(NON_NULL) PaymentResponse payment +) { + +} diff --git a/src/main/java/com/moabam/api/dto/bug/BugHistoryResponse.java b/src/main/java/com/moabam/api/dto/bug/BugHistoryResponse.java new file mode 100644 index 00000000..efbf3df3 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/bug/BugHistoryResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.bug; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record BugHistoryResponse( + List history +) { + +} diff --git a/src/main/java/com/moabam/api/dto/bug/BugHistoryWithPayment.java b/src/main/java/com/moabam/api/dto/bug/BugHistoryWithPayment.java new file mode 100644 index 00000000..30b2ef78 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/bug/BugHistoryWithPayment.java @@ -0,0 +1,21 @@ +package com.moabam.api.dto.bug; + +import java.time.LocalDateTime; + +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.payment.Payment; + +import lombok.Builder; + +@Builder +public record BugHistoryWithPayment( + Long id, + BugType bugType, + BugActionType actionType, + int quantity, + LocalDateTime createdAt, + Payment payment +) { + +} diff --git a/src/main/java/com/moabam/api/dto/bug/BugResponse.java b/src/main/java/com/moabam/api/dto/bug/BugResponse.java new file mode 100644 index 00000000..9493a76c --- /dev/null +++ b/src/main/java/com/moabam/api/dto/bug/BugResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.bug; + +import lombok.Builder; + +@Builder +public record BugResponse( + int morningBug, + int nightBug, + int goldenBug +) { + +} diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java new file mode 100644 index 00000000..d709490e --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java @@ -0,0 +1,25 @@ +package com.moabam.api.dto.coupon; + +import java.time.LocalDate; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.moabam.api.domain.coupon.CouponType; + +import lombok.Builder; + +@Builder +public record CouponResponse( + Long id, + Long adminId, + String name, + String description, + int point, + int maxCount, + CouponType type, + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate startAt, + @JsonFormat(pattern = "yyyy-MM-dd") + LocalDate openAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java b/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java new file mode 100644 index 00000000..0cecaea2 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.coupon; + +import lombok.Builder; + +@Builder +public record CouponStatusRequest( + boolean opened, + boolean ended +) { + +} diff --git a/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java new file mode 100644 index 00000000..e3edcdfb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java @@ -0,0 +1,27 @@ +package com.moabam.api.dto.coupon; + +import java.time.LocalDate; + +import org.hibernate.validator.constraints.Length; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record CreateCouponRequest( + @NotBlank(message = "쿠폰명이 입력되지 않았거나 20자를 넘었습니다.") @Length(max = 20) String name, + @Length(max = 50, message = "쿠폰 간단 소개는 최대 50자까지 가능합니다.") String description, + @NotBlank(message = "쿠폰 종류를 입력해주세요.") String type, + @Min(value = 1, message = "벌레 수 혹은 할인 금액은 1 이상이어야 합니다.") int point, + @Min(value = 1, message = "쿠폰 최대 갯수는 1 이상이어야 합니다.") int maxCount, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + @NotNull(message = "쿠폰 발급이 가능한 날짜(년, 월, 일)를 입력해주세요.") LocalDate startAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + @NotNull(message = "쿠폰 정보창이 열리는 날짜(년, 월, 일)를 입력해주세요.") LocalDate openAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java new file mode 100644 index 00000000..60859b7a --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java @@ -0,0 +1,17 @@ +package com.moabam.api.dto.coupon; + +import com.moabam.api.domain.coupon.CouponType; + +import lombok.Builder; + +@Builder +public record MyCouponResponse( + Long walletId, + Long id, + String name, + String description, + int point, + CouponType type +) { + +} diff --git a/src/main/java/com/moabam/api/dto/item/ItemResponse.java b/src/main/java/com/moabam/api/dto/item/ItemResponse.java new file mode 100644 index 00000000..1ae4ae79 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/item/ItemResponse.java @@ -0,0 +1,17 @@ +package com.moabam.api.dto.item; + +import lombok.Builder; + +@Builder +public record ItemResponse( + Long id, + String type, + String category, + String name, + String image, + int level, + int bugPrice, + int goldenBugPrice +) { + +} diff --git a/src/main/java/com/moabam/api/dto/item/ItemsResponse.java b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java new file mode 100644 index 00000000..70de47ec --- /dev/null +++ b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.item; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record ItemsResponse( + Long defaultItemId, + List purchasedItems, + List notPurchasedItems +) { + +} diff --git a/src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java b/src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java new file mode 100644 index 00000000..0df65e1f --- /dev/null +++ b/src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.item; + +import com.moabam.api.domain.bug.BugType; + +import jakarta.validation.constraints.NotNull; + +public record PurchaseItemRequest( + @NotNull BugType bugType +) { + +} diff --git a/src/main/java/com/moabam/api/dto/member/BadgeResponse.java b/src/main/java/com/moabam/api/dto/member/BadgeResponse.java new file mode 100644 index 00000000..a00158ba --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/BadgeResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.member; + +import com.moabam.api.domain.member.BadgeType; + +import lombok.Builder; + +@Builder +public record BadgeResponse( + String badge, + boolean unlock +) { + +} diff --git a/src/main/java/com/moabam/api/dto/member/DeleteMemberResponse.java b/src/main/java/com/moabam/api/dto/member/DeleteMemberResponse.java new file mode 100644 index 00000000..04b14d26 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/DeleteMemberResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.member; + +import lombok.Builder; + +@Builder +public record DeleteMemberResponse( + String socialId, + Long id +) { + +} diff --git a/src/main/java/com/moabam/api/dto/member/MemberInfo.java b/src/main/java/com/moabam/api/dto/member/MemberInfo.java new file mode 100644 index 00000000..56ed7c77 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/MemberInfo.java @@ -0,0 +1,23 @@ +package com.moabam.api.dto.member; + +import com.moabam.api.domain.member.BadgeType; + +public record MemberInfo( + String nickname, + String profileImage, + String morningImage, + String nightImage, + String intro, + long totalCertifyCount, + BadgeType badges, + Integer goldenBug, + Integer morningBug, + Integer nightBug +) { + + public MemberInfo(String nickname, String profileImage, String morningImage, String nightImage, + String intro, long totalCertifyCount, BadgeType badges) { + this(nickname, profileImage, morningImage, nightImage, intro, + totalCertifyCount, badges, null, null, null); + } +} diff --git a/src/main/java/com/moabam/api/dto/member/MemberInfoResponse.java b/src/main/java/com/moabam/api/dto/member/MemberInfoResponse.java new file mode 100644 index 00000000..c7f37b0c --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/MemberInfoResponse.java @@ -0,0 +1,26 @@ +package com.moabam.api.dto.member; + +import static com.fasterxml.jackson.annotation.JsonInclude.Include.*; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Builder; + +@Builder +public record MemberInfoResponse( + String nickname, + String profileImage, + String intro, + long level, + long exp, + Map birds, + List badges, + @JsonInclude(NON_NULL) Integer goldenBug, + @JsonInclude(NON_NULL) Integer morningBug, + @JsonInclude(NON_NULL) Integer nightBug +) { + +} diff --git a/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java b/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java new file mode 100644 index 00000000..1391e383 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java @@ -0,0 +1,23 @@ +package com.moabam.api.dto.member; + +import java.util.Set; + +import com.moabam.api.domain.member.BadgeType; + +import lombok.Builder; + +@Builder +public record MemberInfoSearchResponse( + String nickname, + String profileImage, + String morningImage, + String nightImage, + String intro, + long totalCertifyCount, + Set badges, + Integer goldenBug, + Integer morningBug, + Integer nightBug +) { + +} diff --git a/src/main/java/com/moabam/api/dto/member/ModifyMemberRequest.java b/src/main/java/com/moabam/api/dto/member/ModifyMemberRequest.java new file mode 100644 index 00000000..43c9ef31 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/ModifyMemberRequest.java @@ -0,0 +1,8 @@ +package com.moabam.api.dto.member; + +public record ModifyMemberRequest( + String intro, + String nickname +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java new file mode 100644 index 00000000..ee5a4d03 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto.payment; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +@Builder +public record ConfirmPaymentRequest( + @NotBlank String paymentKey, + @NotBlank String orderId, + @NotNull @Min(0) int amount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java new file mode 100644 index 00000000..34b5bdb7 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java @@ -0,0 +1,16 @@ +package com.moabam.api.dto.payment; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +import lombok.Builder; + +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public record ConfirmTossPaymentResponse( + String paymentKey, + String orderId, + String orderName, + int totalAmount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/PaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/PaymentRequest.java new file mode 100644 index 00000000..292492a3 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/PaymentRequest.java @@ -0,0 +1,9 @@ +package com.moabam.api.dto.payment; + +import jakarta.validation.constraints.NotBlank; + +public record PaymentRequest( + @NotBlank String orderId +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/PaymentResponse.java b/src/main/java/com/moabam/api/dto/payment/PaymentResponse.java new file mode 100644 index 00000000..29e30af5 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/PaymentResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.payment; + +import lombok.Builder; + +@Builder +public record PaymentResponse( + Long id, + String orderName, + int discountAmount, + int totalAmount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/payment/RequestConfirmPaymentResponse.java b/src/main/java/com/moabam/api/dto/payment/RequestConfirmPaymentResponse.java new file mode 100644 index 00000000..6c3b69ac --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/RequestConfirmPaymentResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.payment; + +import com.moabam.api.domain.payment.Payment; + +import lombok.Builder; + +@Builder +public record RequestConfirmPaymentResponse( + Payment payment, + String paymentKey +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/ProductResponse.java b/src/main/java/com/moabam/api/dto/product/ProductResponse.java new file mode 100644 index 00000000..bd18b595 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/product/ProductResponse.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.product; + +import lombok.Builder; + +@Builder +public record ProductResponse( + Long id, + String type, + String name, + int price, + int quantity +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/ProductsResponse.java b/src/main/java/com/moabam/api/dto/product/ProductsResponse.java new file mode 100644 index 00000000..21b99059 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/product/ProductsResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.product; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record ProductsResponse( + List products +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java new file mode 100644 index 00000000..6e7eda86 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java @@ -0,0 +1,9 @@ +package com.moabam.api.dto.product; + +import jakarta.annotation.Nullable; + +public record PurchaseProductRequest( + @Nullable Long couponWalletId +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/PurchaseProductResponse.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductResponse.java new file mode 100644 index 00000000..8d74cee1 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.product; + +import lombok.Builder; + +@Builder +public record PurchaseProductResponse( + Long paymentId, + String orderName, + int price +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ranking/RankingInfo.java b/src/main/java/com/moabam/api/dto/ranking/RankingInfo.java new file mode 100644 index 00000000..46645389 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ranking/RankingInfo.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.ranking; + +import lombok.Builder; + +@Builder +public record RankingInfo( + Long memberId, + String nickname, + String image +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ranking/TopRankingInfo.java b/src/main/java/com/moabam/api/dto/ranking/TopRankingInfo.java new file mode 100644 index 00000000..bcd56ff2 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ranking/TopRankingInfo.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.ranking; + +import lombok.Builder; + +@Builder +public record TopRankingInfo( + int rank, + Long memberId, + Long score, + String nickname, + String image +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ranking/TopRankingResponse.java b/src/main/java/com/moabam/api/dto/ranking/TopRankingResponse.java new file mode 100644 index 00000000..38663842 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ranking/TopRankingResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.ranking; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record TopRankingResponse( + List topRankings, + TopRankingInfo myRanking +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ranking/UpdateRanking.java b/src/main/java/com/moabam/api/dto/ranking/UpdateRanking.java new file mode 100644 index 00000000..6b5218ee --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ranking/UpdateRanking.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.ranking; + +import lombok.Builder; + +@Builder +public record UpdateRanking( + RankingInfo rankingInfo, + Long score +) { + +} diff --git a/src/main/java/com/moabam/api/dto/report/ReportRequest.java b/src/main/java/com/moabam/api/dto/report/ReportRequest.java new file mode 100644 index 00000000..fdccf2e3 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/report/ReportRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.report; + +import jakarta.validation.constraints.NotNull; + +public record ReportRequest( + @NotNull Long reportedId, + Long roomId, + Long certificationId, + String description +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java b/src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java new file mode 100644 index 00000000..110d6798 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.room; + +import lombok.Builder; + +@Builder +public record CertificationImageResponse( + Long routineId, + String image +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/CertificationImagesResponse.java b/src/main/java/com/moabam/api/dto/room/CertificationImagesResponse.java new file mode 100644 index 00000000..7de93159 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertificationImagesResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record CertificationImagesResponse( + List images +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/CertifiedMemberInfo.java b/src/main/java/com/moabam/api/dto/room/CertifiedMemberInfo.java new file mode 100644 index 00000000..08159788 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertifiedMemberInfo.java @@ -0,0 +1,19 @@ +package com.moabam.api.dto.room; + +import java.time.LocalDate; + +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Room; + +import lombok.Builder; + +@Builder +public record CertifiedMemberInfo( + LocalDate date, + BugType bugType, + Room room, + Member member +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java b/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java new file mode 100644 index 00000000..594942e4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.room; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CertifyRoomRequest { + + private Long routineId; + private MultipartFile image; +} diff --git a/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java b/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java new file mode 100644 index 00000000..c2cf4110 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class CertifyRoomsRequest { + + private List certifyRoomsRequest; +} diff --git a/src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java b/src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java new file mode 100644 index 00000000..a6eeffeb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java @@ -0,0 +1,24 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +import com.moabam.api.domain.room.RoomType; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record CreateRoomRequest( + @NotBlank @Length(max = 20) String title, + @Pattern(regexp = "^(|[0-9]{4,8})$") String password, + @NotNull @Size(min = 1, max = 4) List routines, + @NotNull RoomType roomType, + @Range(min = 0, max = 23) int certifyTime, + @Range(min = 0, max = 10) int maxUserCount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java b/src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java new file mode 100644 index 00000000..fc3d511b --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java @@ -0,0 +1,9 @@ +package com.moabam.api.dto.room; + +import jakarta.validation.constraints.Pattern; + +public record EnterRoomRequest( + @Pattern(regexp = "^(|[0-9]{4,8})$") String password +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java b/src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java new file mode 100644 index 00000000..bff4f0ba --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java @@ -0,0 +1,24 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import com.moabam.api.domain.room.RoomType; + +import lombok.Builder; + +@Builder +public record GetAllRoomResponse( + Long id, + String title, + String image, + boolean isPassword, + String managerNickname, + int level, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + List routines +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java b/src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java new file mode 100644 index 00000000..bb648e72 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record GetAllRoomsResponse( + boolean hasNext, + List rooms +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java b/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java new file mode 100644 index 00000000..cbf9c261 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java @@ -0,0 +1,23 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import com.moabam.api.domain.room.RoomType; + +import lombok.Builder; + +@Builder +public record ManageRoomResponse( + Long roomId, + String title, + Long managerId, + String announcement, + RoomType roomType, + int certifyTime, + int maxUserCount, + String password, + List routines, + List participants +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java new file mode 100644 index 00000000..28d5cdb1 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java @@ -0,0 +1,17 @@ +package com.moabam.api.dto.room; + +import org.hibernate.validator.constraints.Length; +import org.hibernate.validator.constraints.Range; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ModifyRoomRequest( + @NotBlank @Length(max = 20) String title, + @Length(max = 100, message = "방 공지의 길이 100자 이하여야 합니다.") String announcement, + @Pattern(regexp = "^(|\\d{4,8})$") String password, + @Range(min = 0, max = 23) int certifyTime, + @Range(min = 0, max = 10) int maxUserCount +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/MyRoomResponse.java b/src/main/java/com/moabam/api/dto/room/MyRoomResponse.java new file mode 100644 index 00000000..c3480d63 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/MyRoomResponse.java @@ -0,0 +1,20 @@ +package com.moabam.api.dto.room; + +import com.moabam.api.domain.room.RoomType; + +import lombok.Builder; + +@Builder +public record MyRoomResponse( + Long roomId, + String title, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + int obtainedBugs, + boolean isMemberCertifiedToday, + boolean isRoomCertifiedToday +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/MyRoomsResponse.java b/src/main/java/com/moabam/api/dto/room/MyRoomsResponse.java new file mode 100644 index 00000000..8f4a8d14 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/MyRoomsResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record MyRoomsResponse( + List participatingRooms +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/ParticipantResponse.java b/src/main/java/com/moabam/api/dto/room/ParticipantResponse.java new file mode 100644 index 00000000..be3a9bbe --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/ParticipantResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.room; + +import lombok.Builder; + +@Builder +public record ParticipantResponse( + Long memberId, + String nickname, + int contributionPoint, + String profileImage +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java new file mode 100644 index 00000000..466ceee6 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -0,0 +1,33 @@ +package com.moabam.api.dto.room; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.moabam.api.domain.room.RoomType; + +import lombok.Builder; + +@Builder +public record RoomDetailsResponse( + Long roomId, + LocalDateTime roomCreatedAt, + Long myMemberId, + String title, + String managerNickName, + String roomImage, + int level, + int currentExp, + int totalExp, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + String announcement, + double completePercentage, + List certifiedDates, + List routines, + List todayCertificateRank +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/RoomHistoryResponse.java b/src/main/java/com/moabam/api/dto/room/RoomHistoryResponse.java new file mode 100644 index 00000000..44763cfd --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/RoomHistoryResponse.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto.room; + +import java.time.LocalDateTime; + +import lombok.Builder; + +@Builder +public record RoomHistoryResponse( + Long roomId, + String title, + LocalDateTime createdAt, + LocalDateTime deletedAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/RoomsHistoryResponse.java b/src/main/java/com/moabam/api/dto/room/RoomsHistoryResponse.java new file mode 100644 index 00000000..5ce5e40d --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/RoomsHistoryResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record RoomsHistoryResponse( + List roomHistory +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/RoutineResponse.java b/src/main/java/com/moabam/api/dto/room/RoutineResponse.java new file mode 100644 index 00000000..37ae2c16 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/RoutineResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto.room; + +import lombok.Builder; + +@Builder +public record RoutineResponse( + Long routineId, + String content +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java new file mode 100644 index 00000000..c35829d4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java @@ -0,0 +1,18 @@ +package com.moabam.api.dto.room; + +import lombok.Builder; + +@Builder +public record TodayCertificateRankResponse( + int rank, + Long memberId, + String nickname, + boolean isNotificationSent, + String profileImage, + int contributionPoint, + String awakeImage, + String sleepImage, + CertificationImagesResponse certificationImage +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/UnJoinedRoomCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomCertificateRankResponse.java new file mode 100644 index 00000000..0dfe7a21 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomCertificateRankResponse.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto.room; + +import lombok.Builder; + +@Builder +public record UnJoinedRoomCertificateRankResponse( + int rank, + Long memberId, + String nickname, + String awakeImage, + String sleepImage +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java new file mode 100644 index 00000000..3152a163 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java @@ -0,0 +1,27 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import com.moabam.api.domain.room.RoomType; + +import lombok.Builder; + +@Builder +public record UnJoinedRoomDetailsResponse( + Long roomId, + boolean isPassword, + String title, + String roomImage, + int level, + int currentExp, + int totalExp, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + String announcement, + List routines, + List certifiedRanks +) { + +} diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java new file mode 100644 index 00000000..6b6a33a6 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java @@ -0,0 +1,25 @@ +package com.moabam.api.infrastructure.fcm; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class FcmMapper { + + public static Notification toNotification(String title, String body) { + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); + } + + public static Message toMessage(Notification notification, String fcmToken) { + return Message.builder() + .setNotification(notification) + .setToken(fcmToken) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java new file mode 100644 index 00000000..2d57c6b5 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java @@ -0,0 +1,37 @@ +package com.moabam.api.infrastructure.fcm; + +import static java.util.Objects.*; + +import java.time.Duration; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class FcmRepository { + + private static final long EXPIRE_FCM_TOKEN = 60; + + private final ValueRedisRepository valueRedisRepository; + + public void saveToken(String fcmToken, Long memberId) { + String tokenKey = String.valueOf(requireNonNull(memberId)); + + valueRedisRepository.save( + tokenKey, + requireNonNull(fcmToken), + Duration.ofDays(EXPIRE_FCM_TOKEN)); + } + + public void deleteTokenByMemberId(Long memberId) { + valueRedisRepository.delete(String.valueOf(requireNonNull(memberId))); + } + + public String findTokenByMemberId(Long memberId) { + return valueRedisRepository.get(String.valueOf(requireNonNull(memberId))); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java new file mode 100644 index 00000000..6b9fa73b --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -0,0 +1,44 @@ +package com.moabam.api.infrastructure.fcm; + +import java.util.Optional; + +import org.springframework.stereotype.Service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FcmService { + + private final FirebaseMessaging firebaseMessaging; + private final FcmRepository fcmRepository; + + public void createToken(String fcmToken, Long memberId) { + if (fcmToken == null || fcmToken.isBlank()) { + return; + } + + fcmRepository.saveToken(fcmToken, memberId); + } + + public void deleteTokenByMemberId(Long memberId) { + fcmRepository.deleteTokenByMemberId(memberId); + } + + public Optional findTokenByMemberId(Long targetId) { + return Optional.ofNullable(fcmRepository.findTokenByMemberId(targetId)); + } + + public void sendAsync(String fcmToken, String notificationTitle, String notificationBody) { + Notification notification = FcmMapper.toNotification(notificationTitle, notificationBody); + + if (fcmToken != null) { + Message message = FcmMapper.toMessage(notification, fcmToken); + firebaseMessaging.sendAsync(message); + } + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java new file mode 100644 index 00000000..35db1492 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -0,0 +1,55 @@ +package com.moabam.api.infrastructure.payment; + +import java.util.Base64; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.global.config.TossPaymentConfig; +import com.moabam.global.error.exception.TossPaymentException; +import com.moabam.global.error.model.ErrorResponse; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import reactor.core.publisher.Mono; + +@Service +@Slf4j +@RequiredArgsConstructor +public class TossPaymentService { + + private final TossPaymentConfig config; + private WebClient webClient; + + @PostConstruct + public void init() { + this.webClient = WebClient.builder() + .baseUrl(config.baseUrl()) + .defaultHeaders(headers -> { + headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + headers.setBasicAuth(Base64.getEncoder().encodeToString(config.secretKey().getBytes())); + }) + .build(); + } + + public ConfirmTossPaymentResponse confirm(ConfirmPaymentRequest request) { + return webClient.post() + .uri("/v1/payments/confirm") + .body(BodyInserters.fromValue(request)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(ErrorResponse.class) + .flatMap(error -> { + log.error("======= toss-payment confirmation error =======\n{}", error); + return Mono.error(new TossPaymentException(error.message())); + })) + .bodyToMono(ConfirmTossPaymentResponse.class) + .block(); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java new file mode 100644 index 00000000..0af79bc2 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java @@ -0,0 +1,47 @@ +package com.moabam.api.infrastructure.redis; + +import java.time.Duration; +import java.util.Date; +import java.util.Map; + +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.hash.Jackson2HashMapper; +import org.springframework.stereotype.Repository; + +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +@Repository +public class HashRedisRepository { + + private final RedisTemplate redisTemplate; + private final HashOperations hashOperations; + private final Jackson2HashMapper hashMapper; + + public HashRedisRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + hashOperations = redisTemplate.opsForHash(); + hashMapper = new Jackson2HashMapper(false); + } + + // redisTemplate.opsForHash().putAll(key, hashMapper.toHash(value)); + public void save(String key, Object value, Duration timeout) { + hashOperations.putAll(key, hashMapper.toHash(value)); + redisTemplate.expire(key, timeout); + } + + public void delete(String key) { + redisTemplate.expireAt(key, new Date()); + } + + public Object get(String key) { + Map memberToken = hashOperations.entries(key); + + if (memberToken.isEmpty()) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + + return hashMapper.fromHash(memberToken); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java new file mode 100644 index 00000000..8c879bcf --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java @@ -0,0 +1,41 @@ +package com.moabam.api.infrastructure.redis; + +import java.time.Duration; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ValueRedisRepository { + + private final RedisTemplate redisTemplate; + + public void save(String key, String value, Duration timeout) { + redisTemplate + .opsForValue() + .set(key, value, timeout); + } + + public Long increment(String key, long delta) { + return redisTemplate + .opsForValue() + .increment(key, delta); + } + + public String get(String key) { + return (String)redisTemplate + .opsForValue() + .get(key); + } + + public Boolean hasKey(String key) { + return redisTemplate.hasKey(key); + } + + public void delete(String key) { + redisTemplate.delete(key); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java new file mode 100644 index 00000000..389ff604 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -0,0 +1,90 @@ +package com.moabam.api.infrastructure.redis; + +import static java.util.Objects.*; + +import java.time.Duration; +import java.util.Set; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ZSetRedisRepository { + + private final RedisTemplate redisTemplate; + + public void addIfAbsent(String key, Object value, double score, int expire) { + redisTemplate + .opsForZSet() + .addIfAbsent(requireNonNull(key), requireNonNull(value), score); + redisTemplate + .expire(key, Duration.ofDays(expire)); + } + + public Set range(String key, long start, long end) { + return redisTemplate + .opsForZSet() + .range(key, start, end); + } + + public Long rank(String key, Object value) { + return redisTemplate + .opsForZSet() + .rank(key, value); + } + + public Double score(String key, Object value) { + return redisTemplate + .opsForZSet() + .score(key, value); + } + + public Long size(String key) { + return redisTemplate + .opsForZSet() + .zCard(key); + } + + public void add(String key, Object value, double score) { + redisTemplate + .opsForZSet() + .add(requireNonNull(key), requireNonNull(value), score); + } + + public void changeMember(String key, Object before, Object after) { + Double score = redisTemplate.opsForZSet().score(key, before); + + if (score == null) { + return; + } + + delete(key, before); + add(key, after, score); + } + + public void delete(String key, Object value) { + redisTemplate.opsForZSet().remove(key, value); + } + + public Set> rangeJson(String key, int startIndex, int limitIndex) { + setSerialize(Object.class); + Set> rankings = redisTemplate.opsForZSet() + .reverseRangeWithScores(key, startIndex, limitIndex); + setSerialize(String.class); + return rankings; + } + + public Long reverseRank(String key, Object myRankingInfo) { + return redisTemplate.opsForZSet().reverseRank(key, myRankingInfo); + } + + private void setSerialize(Class classes) { + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(classes)); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java b/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java new file mode 100644 index 00000000..45bbb1b3 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java @@ -0,0 +1,47 @@ +package com.moabam.api.infrastructure.s3; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.global.error.exception.BadRequestException; + +import io.awspring.cloud.s3.ObjectMetadata; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class S3Manager { + + private final S3Template s3Template; + + @Value("${spring.cloud.aws.s3.bucket}") + private String bucket; + + @Value("${spring.cloud.aws.s3.url}") + private String s3BaseUrl; + + @Value("${spring.cloud.aws.cloud-front.url}") + private String cloudFrontUrl; + + public String uploadImage(String key, MultipartFile file) { + try { + s3Template.upload(bucket, key, file.getInputStream(), + ObjectMetadata.builder().contentType("image/png").build()); + + return cloudFrontUrl + key; + } catch (IOException e) { + throw new BadRequestException(S3_UPLOAD_FAIL); + } + } + + public void deleteImage(String objectUrl) { + String s3Url = objectUrl.replace(cloudFrontUrl, s3BaseUrl); + s3Template.deleteObject(s3Url); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java b/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java new file mode 100644 index 00000000..82e94548 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java @@ -0,0 +1,74 @@ +package com.moabam.api.infrastructure.slack; + +import static java.util.stream.Collectors.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import org.springframework.stereotype.Component; + +import net.gpedro.integrations.slack.SlackAttachment; +import net.gpedro.integrations.slack.SlackField; +import net.gpedro.integrations.slack.SlackMessage; + +import com.moabam.global.common.util.DateUtils; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class SlackMessageFactory { + + private static final String ERROR_TITLE = "에러가 발생했습니다 🚨"; + + public SlackMessage generateErrorMessage(HttpServletRequest request, Exception exception) throws IOException { + return new SlackMessage() + .setAttachments(generateAttachments(request, exception)) + .setText(ERROR_TITLE); + } + + private List generateAttachments(HttpServletRequest request, Exception exception) throws + IOException { + return List.of(new SlackAttachment() + .setFallback("Error") + .setColor("danger") + .setTitleLink(request.getContextPath()) + .setText(formatException(exception)) + .setColor("danger") + .setFields(generateFields(request))); + } + + private String formatException(Exception exception) { + return String.format("📍 Exception Class%n%s%n📍 Exception Message%n%s%n%s", + exception.getClass().getName(), + exception.getMessage(), + Arrays.toString(exception.getStackTrace())); + } + + private List generateFields(HttpServletRequest request) throws IOException { + return List.of( + new SlackField().setTitle("✅ Request Method").setValue(request.getMethod()), + new SlackField().setTitle("✅ Request URL").setValue(request.getRequestURL().toString()), + new SlackField().setTitle("✅ Request Time").setValue(DateUtils.format(LocalDateTime.now())), + new SlackField().setTitle("✅ Request IP").setValue(request.getRemoteAddr()), + new SlackField().setTitle("✅ Request Headers").setValue(request.toString()), + new SlackField().setTitle("✅ Request Body").setValue(getRequestBody(request)) + ); + } + + private String getRequestBody(HttpServletRequest request) throws IOException { + String body; + + try ( + InputStream inputStream = request.getInputStream(); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)) + ) { + body = bufferedReader.lines().collect(joining(System.lineSeparator())); + } + return body; + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java b/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java new file mode 100644 index 00000000..a5295d1a --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java @@ -0,0 +1,26 @@ +package com.moabam.api.infrastructure.slack; + +import java.io.IOException; + +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +import net.gpedro.integrations.slack.SlackApi; +import net.gpedro.integrations.slack.SlackMessage; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SlackService { + + private final SlackApi slackApi; + private final SlackMessageFactory slackMessageFactory; + private final TaskExecutor taskExecutor; + + public void send(HttpServletRequest request, Exception exception) throws IOException { + SlackMessage slackMessage = slackMessageFactory.generateErrorMessage(request, exception); + taskExecutor.execute(() -> slackApi.call(slackMessage)); + } +} diff --git a/src/main/java/com/moabam/api/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java new file mode 100644 index 00000000..51246958 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -0,0 +1,55 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.dto.bug.BugHistoryResponse; +import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; +import com.moabam.api.dto.product.PurchaseProductRequest; +import com.moabam.api.dto.product.PurchaseProductResponse; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/bugs") +@RequiredArgsConstructor +public class BugController { + + private final BugService bugService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public BugResponse getBug(@Auth AuthMember member) { + return bugService.getBug(member.id()); + } + + @GetMapping("/history") + @ResponseStatus(HttpStatus.OK) + public BugHistoryResponse getBugHistory(@Auth AuthMember member) { + return bugService.getBugHistory(member.id()); + } + + @GetMapping("/products") + @ResponseStatus(HttpStatus.OK) + public ProductsResponse getBugProducts() { + return bugService.getBugProducts(); + } + + @PostMapping("/products/{productId}/purchase") + @ResponseStatus(HttpStatus.OK) + public PurchaseProductResponse purchaseBugProduct(@Auth AuthMember member, @PathVariable Long productId, + @Valid @RequestBody PurchaseProductRequest request) { + return bugService.purchaseBugProduct(member.id(), productId, request); + } +} diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java new file mode 100644 index 00000000..01931878 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -0,0 +1,78 @@ +package com.moabam.api.presentation; + +import java.util.List; + +import org.springframework.http.HttpStatus; +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.coupon.CouponManageService; +import com.moabam.api.application.coupon.CouponService; +import com.moabam.api.dto.coupon.CouponResponse; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.api.dto.coupon.MyCouponResponse; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class CouponController { + + private final CouponService couponService; + private final CouponManageService couponManageService; + + @PostMapping("/admins/coupons") + @ResponseStatus(HttpStatus.CREATED) + public void create(@Valid @RequestBody CreateCouponRequest request, @Auth AuthMember admin) { + couponService.create(request, admin.id(), admin.role()); + } + + @DeleteMapping("/admins/coupons/{couponId}") + @ResponseStatus(HttpStatus.OK) + public void delete(@PathVariable("couponId") Long couponId, @Auth AuthMember admin) { + couponService.delete(couponId, admin.role()); + } + + @GetMapping("/coupons/{couponId}") + @ResponseStatus(HttpStatus.OK) + public CouponResponse getById(@PathVariable("couponId") Long couponId) { + return couponService.getById(couponId); + } + + @PostMapping("/coupons/search") + @ResponseStatus(HttpStatus.OK) + public List getAllByStatus(@Valid @RequestBody CouponStatusRequest request) { + return couponService.getAllByStatus(request); + } + + @GetMapping({"/my-coupons", "/my-coupons/{couponWalletId}"}) + @ResponseStatus(HttpStatus.OK) + public List getAllByWalletIdAndMemberId( + @PathVariable(value = "couponWalletId", required = false) Long couponWalletId, + @Auth AuthMember authMember + ) { + return couponService.getAllByWalletIdAndMemberId(couponWalletId, authMember.id()); + } + + @PostMapping("/my-coupons/{couponWalletId}") + @ResponseStatus(HttpStatus.OK) + public void use(@PathVariable("couponWalletId") Long couponWalletId, @Auth AuthMember authMember) { + couponService.use(couponWalletId, authMember.id()); + } + + @PostMapping("/coupons") + @ResponseStatus(HttpStatus.OK) + public void registerQueue(@RequestParam("couponName") String couponName, @Auth AuthMember authMember) { + couponManageService.registerQueue(couponName, authMember.id()); + } +} diff --git a/src/main/java/com/moabam/api/presentation/HealthCheckController.java b/src/main/java/com/moabam/api/presentation/HealthCheckController.java new file mode 100644 index 00000000..5d72ea28 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/HealthCheckController.java @@ -0,0 +1,24 @@ +package com.moabam.api.presentation; + +import java.time.LocalDateTime; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class HealthCheckController { + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public String healthCheck() { + return "Health Check Success"; + } + + @GetMapping("/serverTime") + @ResponseStatus(HttpStatus.OK) + public String serverTimeCheck() { + return LocalDateTime.now().toString(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java new file mode 100644 index 00000000..7b55c155 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -0,0 +1,48 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.item.ItemService; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.dto.item.ItemsResponse; +import com.moabam.api.dto.item.PurchaseItemRequest; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/items") +@RequiredArgsConstructor +public class ItemController { + + private final ItemService itemService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ItemsResponse getItems(@Auth AuthMember member, @RequestParam ItemType type) { + return itemService.getItems(member.id(), type); + } + + @PostMapping("/{itemId}/purchase") + @ResponseStatus(HttpStatus.OK) + public void purchaseItem(@Auth AuthMember member, @PathVariable Long itemId, + @Valid @RequestBody PurchaseItemRequest request) { + itemService.purchaseItem(member.id(), itemId, request); + } + + @PostMapping("/{itemId}/select") + @ResponseStatus(HttpStatus.OK) + public void selectItem(@Auth AuthMember member, @PathVariable Long itemId) { + itemService.selectItem(member.id(), itemId); + } +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java new file mode 100644 index 00000000..4891f5a3 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -0,0 +1,92 @@ +package com.moabam.api.presentation; + +import java.util.List; + +import org.springframework.http.HttpStatus; +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.RequestPart; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.member.MemberInfoResponse; +import com.moabam.api.dto.member.ModifyMemberRequest; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.error.exception.BadRequestException; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final AuthorizationService authorizationService; + private final MemberService memberService; + private final ImageService imageService; + + @GetMapping("/login/oauth") + public void socialLogin(HttpServletResponse httpServletResponse) { + authorizationService.redirectToLoginPage(httpServletResponse); + } + + @PostMapping("/login/kakao/oauth") + @ResponseStatus(HttpStatus.OK) + public LoginResponse authorizationTokenIssue(@RequestBody AuthorizationCodeResponse authorizationCodeResponse, + HttpServletResponse httpServletResponse) { + AuthorizationTokenResponse tokenResponse = authorizationService.requestToken(authorizationCodeResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = authorizationService.requestTokenInfo( + tokenResponse); + + return authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + } + + @GetMapping("/logout") + @ResponseStatus(HttpStatus.OK) + public void logout(@Auth AuthMember authMember, HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse) { + authorizationService.logout(authMember, httpServletRequest, httpServletResponse); + } + + @DeleteMapping + @ResponseStatus(HttpStatus.OK) + public void deleteMember(@Auth AuthMember authMember) { + authorizationService.unLinkMember(authMember); + } + + @GetMapping(value = {"", "/{memberId}"}) + public MemberInfoResponse searchInfo(@Auth AuthMember authMember, @PathVariable(required = false) Long memberId) { + return memberService.searchInfo(authMember, memberId); + } + + @PostMapping("/modify") + public void modifyMember(@Auth AuthMember authMember, + @RequestPart(required = false) ModifyMemberRequest modifyMemberRequest, + @RequestPart(name = "profileImage", required = false) MultipartFile newProfileImage) { + String newProfileUri = null; + + try { + newProfileUri = imageService.uploadImages(List.of(newProfileImage), ImageType.PROFILE_IMAGE).get(0); + } catch (BadRequestException | NullPointerException e) { + // Do nothing + } + + memberService.modifyInfo(authMember, modifyMemberRequest, newProfileUri); + } +} diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java new file mode 100644 index 00000000..85434343 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -0,0 +1,42 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.notification.NotificationService; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +public class NotificationController { + + private final NotificationService notificationService; + private final FcmService fcmService; + + @GetMapping("/rooms/{roomId}/members/{memberId}") + @ResponseStatus(HttpStatus.OK) + public void sendKnock( + @PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId, + @Auth AuthMember authMember + ) { + notificationService.sendKnock(roomId, memberId, authMember.id()); + } + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public void createFcmToken(@RequestParam("fcmToken") String fcmToken, @Auth AuthMember authMember) { + fcmService.createToken(fcmToken, authMember.id()); + } +} diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java new file mode 100644 index 00000000..ab613b73 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -0,0 +1,41 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.payment.PaymentService; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.dto.payment.RequestConfirmPaymentResponse; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/{paymentId}") + @ResponseStatus(HttpStatus.OK) + public void request(@Auth AuthMember member, @PathVariable Long paymentId, + @Valid @RequestBody PaymentRequest request) { + paymentService.request(member.id(), paymentId, request); + } + + @PostMapping("/confirm") + @ResponseStatus(HttpStatus.OK) + public void confirm(@Auth AuthMember member, @Valid @RequestBody ConfirmPaymentRequest request) { + RequestConfirmPaymentResponse response = paymentService.requestConfirm(member.id(), request); + paymentService.confirm(member.id(), response.payment(), response.paymentKey()); + } +} diff --git a/src/main/java/com/moabam/api/presentation/RankingController.java b/src/main/java/com/moabam/api/presentation/RankingController.java new file mode 100644 index 00000000..d218ca5d --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/RankingController.java @@ -0,0 +1,33 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.ranking.RankingService; +import com.moabam.api.dto.ranking.TopRankingResponse; +import com.moabam.api.dto.ranking.UpdateRanking; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import lombok.RequiredArgsConstructor; + +@RequestMapping("/rankings") +@RestController +@RequiredArgsConstructor +public class RankingController { + + private final RankingService rankingService; + private final MemberService memberService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public TopRankingResponse getRanking(@Auth AuthMember authMember) { + UpdateRanking rankingInfo = memberService.getRankingInfo(authMember); + + return rankingService.getMemberRanking(rankingInfo); + } +} diff --git a/src/main/java/com/moabam/api/presentation/ReportController.java b/src/main/java/com/moabam/api/presentation/ReportController.java new file mode 100644 index 00000000..cd23d3fd --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/ReportController.java @@ -0,0 +1,30 @@ +package com.moabam.api.presentation; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.report.ReportService; +import com.moabam.api.dto.report.ReportRequest; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/reports") +@RequiredArgsConstructor +public class ReportController { + + private final ReportService reportService; + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public void reports(@Auth AuthMember authMember, @Valid @RequestBody ReportRequest reportRequest) { + reportService.report(authMember, reportRequest); + } +} diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java new file mode 100644 index 00000000..ffc3c52b --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -0,0 +1,155 @@ +package com.moabam.api.presentation; + +import java.time.LocalDate; +import java.util.List; + +import org.springframework.http.HttpStatus; +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.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.room.CertificationService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.application.room.SearchService; +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.domain.image.NewImage; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.dto.room.CertifiedMemberInfo; +import com.moabam.api.dto.room.CertifyRoomsRequest; +import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.api.dto.room.EnterRoomRequest; +import com.moabam.api.dto.room.GetAllRoomsResponse; +import com.moabam.api.dto.room.ManageRoomResponse; +import com.moabam.api.dto.room.ModifyRoomRequest; +import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.api.dto.room.RoomDetailsResponse; +import com.moabam.api.dto.room.RoomsHistoryResponse; +import com.moabam.api.dto.room.UnJoinedRoomDetailsResponse; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@Slf4j +@RequestMapping("/rooms") +public class RoomController { + + private final RoomService roomService; + private final SearchService searchService; + private final CertificationService certificationService; + private final ImageService imageService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public Long createRoom(@Auth AuthMember authMember, @Valid @RequestBody CreateRoomRequest createRoomRequest) { + return roomService.createRoom(authMember.id(), createRoomRequest); + } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public GetAllRoomsResponse getAllRooms(@RequestParam(value = "roomType", required = false) RoomType roomType, + @RequestParam(value = "roomId", required = false) Long roomId) { + return searchService.getAllRooms(roomType, roomId); + } + + @GetMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public ManageRoomResponse getRoomForModification(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId) { + return searchService.getRoomForModification(authMember.id(), roomId); + } + + @PutMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void modifyRoom(@Auth AuthMember authMember, @Valid @RequestBody ModifyRoomRequest modifyRoomRequest, + @PathVariable("roomId") Long roomId) { + roomService.modifyRoom(authMember.id(), roomId, modifyRoomRequest); + } + + @PostMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void enterRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, + @Valid @RequestBody EnterRoomRequest enterRoomRequest) { + roomService.enterRoom(authMember.id(), roomId, enterRoomRequest); + } + + @DeleteMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void exitRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId) { + roomService.exitRoom(authMember.id(), roomId); + } + + @GetMapping("/{roomId}/check") + @ResponseStatus(HttpStatus.OK) + public boolean checkIfParticipant(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId) { + return roomService.checkIfParticipant(authMember.id(), roomId); + } + + @GetMapping("/{roomId}/un-joined") + @ResponseStatus(HttpStatus.OK) + public UnJoinedRoomDetailsResponse getUnJoinedRoomDetails(@PathVariable("roomId") Long roomId) { + return searchService.getUnJoinedRoomDetails(roomId); + } + + @GetMapping("/{roomId}/{date}") + @ResponseStatus(HttpStatus.OK) + public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, + @PathVariable("date") LocalDate date) { + return searchService.getRoomDetails(authMember.id(), roomId, date); + } + + @PostMapping("/{roomId}/certification") + @ResponseStatus(HttpStatus.CREATED) + public void certifyRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, + CertifyRoomsRequest request) { + List images = imageService.getNewImages(request); + List imageUrls = imageService.uploadImages(images, ImageType.CERTIFICATION); + CertifiedMemberInfo info = certificationService.getCertifiedMemberInfo(authMember.id(), roomId, imageUrls); + certificationService.certifyRoom(info); + } + + @PutMapping("/{roomId}/members/{memberId}/mandate") + @ResponseStatus(HttpStatus.OK) + public void mandateManager(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId) { + roomService.mandateManager(authMember.id(), roomId, memberId); + } + + @DeleteMapping("/{roomId}/members/{memberId}") + @ResponseStatus(HttpStatus.OK) + public void deportParticipant(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId) { + roomService.deportParticipant(authMember.id(), roomId, memberId); + } + + @GetMapping("/my-join") + @ResponseStatus(HttpStatus.OK) + public MyRoomsResponse getMyRooms(@Auth AuthMember authMember) { + return searchService.getMyRooms(authMember.id()); + } + + @GetMapping("/join-history") + @ResponseStatus(HttpStatus.OK) + public RoomsHistoryResponse getJoinHistory(@Auth AuthMember authMember) { + return searchService.getJoinHistory(authMember.id()); + } + + @GetMapping("/search") + @ResponseStatus(HttpStatus.OK) + public GetAllRoomsResponse searchRooms(@RequestParam(value = "keyword") String keyword, + @RequestParam(value = "roomType", required = false) RoomType roomType, + @RequestParam(value = "roomId", required = false) Long roomId) { + return searchService.searchRooms(keyword, roomType, roomId); + } +} diff --git a/src/main/java/com/moabam/global/auth/annotation/Auth.java b/src/main/java/com/moabam/global/auth/annotation/Auth.java new file mode 100644 index 00000000..28634b27 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/annotation/Auth.java @@ -0,0 +1,12 @@ +package com.moabam.global.auth.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface Auth { + +} diff --git a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java new file mode 100644 index 00000000..d9f1c8d0 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java @@ -0,0 +1,130 @@ +package com.moabam.global.auth.filter; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; + +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.JwtAuthenticationService; +import com.moabam.api.application.auth.mapper.AuthorizationMapper; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Order(2) +@Component +@RequiredArgsConstructor +public class AuthorizationFilter extends OncePerRequestFilter { + + private final HandlerExceptionResolver handlerExceptionResolver; + private final JwtAuthenticationService authenticationService; + private final AuthorizationService authorizationService; + + @Override + protected void doFilterInternal(@NotNull HttpServletRequest httpServletRequest, + @NotNull HttpServletResponse httpServletResponse, @NotNull FilterChain filterChain) throws + ServletException, + IOException { + + if (isPermit(httpServletRequest)) { + filterChain.doFilter(httpServletRequest, httpServletResponse); + return; + } + + try { + invoke(httpServletRequest, httpServletResponse); + } catch (UnauthorizedException unauthorizedException) { + authorizationService.removeToken(httpServletRequest, httpServletResponse); + handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, + unauthorizedException); + + return; + } + + filterChain.doFilter(httpServletRequest, httpServletResponse); + } + + private boolean isPermit(HttpServletRequest httpServletRequest) { + Boolean isPermit = (Boolean)httpServletRequest.getAttribute("isPermit"); + + return Objects.nonNull(isPermit) && Boolean.TRUE.equals(isPermit); + } + + private void invoke(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + Cookie[] cookies = getCookiesOrThrow(httpServletRequest); + + if (!isTokenTypeBearer(cookies)) { + throw new UnauthorizedException(ErrorMessage.TOKEN_TYPE_FAILED); + } + + handleTokenAuthenticate(cookies, httpServletResponse, httpServletRequest); + } + + private boolean isTokenTypeBearer(Cookie[] cookies) { + return "Bearer".equals(extractTokenFromCookie(cookies, "token_type")); + } + + private void handleTokenAuthenticate(Cookie[] cookies, HttpServletResponse httpServletResponse, + HttpServletRequest httpServletRequest) { + String accessToken = extractTokenFromCookie(cookies, "access_token"); + PublicClaim publicClaim = authenticationService.parseClaim(accessToken); + + if (authenticationService.isTokenExpire(accessToken, publicClaim.role())) { + String refreshToken = extractTokenFromCookie(cookies, "refresh_token"); + + if (authenticationService.isTokenExpire(refreshToken, publicClaim.role())) { + throw new UnauthorizedException(ErrorMessage.TOKEN_EXPIRE); + } + + validInvalidMember(publicClaim, refreshToken, httpServletRequest); + authorizationService.issueServiceToken(httpServletResponse, publicClaim); + } + + AuthorizationThreadLocal.setAuthMember(AuthorizationMapper.toAuthMember(publicClaim)); + } + + private void validInvalidMember(PublicClaim publicClaim, String refreshToken, + HttpServletRequest httpServletRequest) { + boolean isAdminPath = httpServletRequest.getRequestURI().contains("admins"); + + if (!((publicClaim.role().equals(Role.ADMIN) && isAdminPath) || (publicClaim.role().equals(Role.USER) + && !isAdminPath))) { + throw new BadRequestException(ErrorMessage.INVALID_REQUEST_ROLE); + } + + authorizationService.validTokenPair(publicClaim.id(), refreshToken, publicClaim.role()); + authorizationService.validMemberExist(publicClaim.id(), publicClaim.role()); + } + + private Cookie[] getCookiesOrThrow(HttpServletRequest httpServletRequest) { + return Optional.ofNullable(httpServletRequest.getCookies()) + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.COOKIE_NOT_FOUND)); + } + + private String extractTokenFromCookie(Cookie[] cookies, String tokenName) { + return Arrays.stream(cookies) + .filter(cookie -> tokenName.equals(cookie.getName())) + .map(Cookie::getValue) + .findFirst() + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.TOKEN_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java new file mode 100644 index 00000000..bcd7e4d8 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java @@ -0,0 +1,79 @@ +package com.moabam.global.auth.filter; + +import java.io.IOException; +import java.util.Objects; + +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import com.google.cloud.storage.HttpMethod; +import com.moabam.global.config.AllowOriginConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Order(0) +@Component +@RequiredArgsConstructor +public class CorsFilter extends OncePerRequestFilter { + + private static final String ALLOWED_METHOD_NAMES = "GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH"; + private static final String ALLOWED_HEADERS = "Origin, Accept, Access-Control-Request-Method, " + + "Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer"; + + private final HandlerExceptionResolver handlerExceptionResolver; + + private final AllowOriginConfig allowOriginsConfig; + + @Override + protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + FilterChain filterChain) throws ServletException, IOException { + String refer = getReferer(httpServletRequest); + + try { + if (Objects.isNull(refer)) { + throw new UnauthorizedException(ErrorMessage.INVALID_REQUEST_URL); + } + } catch (UnauthorizedException unauthorizedException) { + handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, + unauthorizedException); + + return; + } + + String origin = secureMatch(refer); + + httpServletResponse.setHeader("Access-Control-Allow-Origin", origin); + httpServletResponse.setHeader("Access-Control-Allow-Methods", ALLOWED_METHOD_NAMES); + httpServletResponse.setHeader("Access-Control-Allow-Headers", ALLOWED_HEADERS); + httpServletResponse.setHeader("Access-Control-Allow-Credentials", "true"); + httpServletResponse.setHeader("Access-Control-Max-Age", "3600"); + + if (isOption(httpServletRequest.getMethod())) { + httpServletRequest.setAttribute("isPermit", true); + } + + filterChain.doFilter(httpServletRequest, httpServletResponse); + } + + public String getReferer(HttpServletRequest httpServletRequest) { + return httpServletRequest.getHeader("referer"); + } + + public String secureMatch(String refer) { + return allowOriginsConfig.origin().stream().filter(refer::contains).findFirst().orElse(null); + } + + public boolean isOption(String method) { + return HttpMethod.OPTIONS.name().equals(method); + } +} diff --git a/src/main/java/com/moabam/global/auth/filter/PathFilter.java b/src/main/java/com/moabam/global/auth/filter/PathFilter.java new file mode 100644 index 00000000..8c7266ee --- /dev/null +++ b/src/main/java/com/moabam/global/auth/filter/PathFilter.java @@ -0,0 +1,48 @@ +package com.moabam.global.auth.filter; + +import java.io.IOException; +import java.util.Optional; + +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.moabam.global.auth.handler.PathResolver; + +import io.grpc.netty.shaded.io.netty.handler.codec.http.HttpMethod; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Order(1) +@Component +@RequiredArgsConstructor +public class PathFilter extends OncePerRequestFilter { + + private final PathResolver pathResolver; + + @Override + public void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + Optional matchedPath = pathResolver.permitPathMatch(request.getRequestURI()); + + matchedPath.ifPresent(path -> { + if (path.httpMethods().stream() + .anyMatch(httpMethod -> httpMethod.matches(request.getMethod()))) { + request.setAttribute("isPermit", true); + } + }); + + if (isOption(request.getMethod())) { + request.setAttribute("isPermit", true); + } + + filterChain.doFilter(request, response); + } + + public boolean isOption(String method) { + return HttpMethod.OPTIONS.name().equals(method); + } +} diff --git a/src/main/java/com/moabam/global/auth/handler/AuthArgumentResolver.java b/src/main/java/com/moabam/global/auth/handler/AuthArgumentResolver.java new file mode 100644 index 00000000..08fd83b2 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/handler/AuthArgumentResolver.java @@ -0,0 +1,31 @@ +package com.moabam.global.auth.handler; + +import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; + +import java.util.Objects; + +import javax.annotation.Nullable; + +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.model.AuthMember; + +public class AuthArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Objects.nonNull(parameter.getParameterAnnotation(Auth.class)) + && parameter.getParameterType().equals(AuthMember.class); + } + + @Override + public Object resolveArgument(@Nullable MethodParameter parameter, ModelAndViewContainer mavContainer, + @Nullable NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return getAuthMember(); + } +} diff --git a/src/main/java/com/moabam/global/auth/handler/PathResolver.java b/src/main/java/com/moabam/global/auth/handler/PathResolver.java new file mode 100644 index 00000000..46d2c32d --- /dev/null +++ b/src/main/java/com/moabam/global/auth/handler/PathResolver.java @@ -0,0 +1,85 @@ +package com.moabam.global.auth.handler; + +import static java.util.Objects.*; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.http.HttpMethod; +import org.springframework.http.server.PathContainer; +import org.springframework.web.util.pattern.PathPattern; +import org.springframework.web.util.pattern.PathPatternParser; + +import com.moabam.api.domain.member.Role; + +import lombok.Builder; +import lombok.Singular; + +public class PathResolver { + + private final Map permitPatterns; + private final Map authenticationPatterns; + + public PathResolver(Paths paths) { + this.permitPatterns = Paths.pathParser(paths.permitAll); + this.authenticationPatterns = Paths.pathParser(paths.authentications); + } + + public Optional permitPathMatch(String uri) { + return match(permitPatterns, uri); + } + + public Optional authenticationsPatterns(String uri) { + return match(authenticationPatterns, uri); + } + + private Optional match(Map patterns, String uri) { + Set paths = patterns.keySet(); + PathContainer path = PathContainer.parsePath(uri); + PathPattern matchedPattern = paths.stream() + .filter(pathPattern -> pathPattern.matches(path)) + .findAny() + .orElse(null); + + return Optional.ofNullable(patterns.get(matchedPattern)); + } + + @Builder + public record Paths( + @Singular("permitOne") List permitAll, + @Singular("authentication") List authentications + ) { + + static Map pathParser(List uris) { + PathPatternParser parser = new PathPatternParser(); + return uris.stream() + .collect(Collectors.toMap( + path -> parser.parse(path.uri()), Function.identity() + )); + } + } + + public record Path( + String uri, + List httpMethods, + List roles + ) { + + private static final List BASE_METHODS = + List.of(HttpMethod.GET, HttpMethod.POST, HttpMethod.DELETE, HttpMethod.PUT, HttpMethod.PATCH); + + @Builder + public Path(String uri, @Singular("httpMethod") List httpMethods, + @Singular("role") List roles) { + this.uri = requireNonNull(uri); + this.roles = Optional.of(roles).filter(role -> !role.isEmpty()).orElse(List.of(Role.USER)); + this.httpMethods = Optional.of(httpMethods) + .filter(httpMethod -> !httpMethod.isEmpty()) + .orElse(BASE_METHODS); + } + } +} diff --git a/src/main/java/com/moabam/global/auth/model/AuthMember.java b/src/main/java/com/moabam/global/auth/model/AuthMember.java new file mode 100644 index 00000000..2e434766 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/model/AuthMember.java @@ -0,0 +1,11 @@ +package com.moabam.global.auth.model; + +import com.moabam.api.domain.member.Role; + +public record AuthMember( + Long id, + String nickname, + Role role +) { + +} diff --git a/src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java b/src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java new file mode 100644 index 00000000..7f1250ab --- /dev/null +++ b/src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java @@ -0,0 +1,26 @@ +package com.moabam.global.auth.model; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthorizationThreadLocal { + + private static final ThreadLocal authMember; + + static { + authMember = new ThreadLocal<>(); + } + + public static void setAuthMember(AuthMember authMember) { + AuthorizationThreadLocal.authMember.set(authMember); + } + + public static AuthMember getAuthMember() { + return authMember.get(); + } + + public static void remove() { + authMember.remove(); + } +} diff --git a/src/main/java/com/moabam/global/auth/model/PublicClaim.java b/src/main/java/com/moabam/global/auth/model/PublicClaim.java new file mode 100644 index 00000000..cc23bdec --- /dev/null +++ b/src/main/java/com/moabam/global/auth/model/PublicClaim.java @@ -0,0 +1,15 @@ +package com.moabam.global.auth.model; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.moabam.api.domain.member.Role; + +import lombok.Builder; + +@Builder +public record PublicClaim( + Long id, + @JsonIgnore String nickname, + @JsonIgnore Role role +) { + +} diff --git a/src/main/java/com/moabam/global/common/entity/BaseTimeEntity.java b/src/main/java/com/moabam/global/common/entity/BaseTimeEntity.java new file mode 100644 index 00000000..07b008fa --- /dev/null +++ b/src/main/java/com/moabam/global/common/entity/BaseTimeEntity.java @@ -0,0 +1,29 @@ +package com.moabam.global.common.entity; + +import java.time.LocalDateTime; + +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@MappedSuperclass +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + @Column(name = "created_at", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at") + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/moabam/global/common/util/BaseDataCode.java b/src/main/java/com/moabam/global/common/util/BaseDataCode.java new file mode 100644 index 00000000..59098690 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/BaseDataCode.java @@ -0,0 +1,11 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaseDataCode { + + public static final Long MORNING_EGG = 1L; + public static final Long NIGHT_EGG = 2L; +} diff --git a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java new file mode 100644 index 00000000..c430933a --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -0,0 +1,20 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaseImageUrl { + + public static final String IMAGE_DOMAIN = "https://image.moabam.com/"; + + public static final String DEFAULT_SKIN_URL = ""; + public static final String DEFAULT_MORNING_AWAKE_SKIN_URL = ""; + public static final String DEFAULT_MORNING_SLEEP_SKIN_URL = ""; + public static final String DEFAULT_NIGHT_AWAKE_SKIN_URL = ""; + public static final String DEFAULT_NIGHT_SLEEP_SKIN_URL = ""; + + public static final String DEFAULT_MORNING_EGG_URL = "moabam/skins/omok/default/egg.png"; + public static final String DEFAULT_NIGHT_EGG_URL = "moabam/skins/owl/default/egg.png"; + public static final String MEMBER_PROFILE_URL = "moabam/default/member-profile.png"; +} diff --git a/src/main/java/com/moabam/global/common/util/ClockHolder.java b/src/main/java/com/moabam/global/common/util/ClockHolder.java new file mode 100644 index 00000000..1ba7a0c5 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/ClockHolder.java @@ -0,0 +1,11 @@ +package com.moabam.global.common.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +public interface ClockHolder { + + LocalDateTime times(); + + LocalDate date(); +} diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java new file mode 100644 index 00000000..53aaa881 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -0,0 +1,39 @@ +package com.moabam.global.common.util; + +import jakarta.servlet.http.Cookie; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class CookieUtils { + + public static Cookie tokenCookie(String name, String value, long expireTime, String domain) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setDomain("moabam.com"); + cookie.setMaxAge((int)expireTime); + cookie.setAttribute("SameSite", "None"); + + return cookie; + } + + public static Cookie typeCookie(String value, long expireTime, String domain) { + Cookie cookie = new Cookie("token_type", value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setDomain("moabam.com"); + cookie.setMaxAge((int)expireTime); + cookie.setAttribute("SameSite", "None"); + + return cookie; + } + + public static Cookie deleteCookie(Cookie cookie) { + cookie.setMaxAge(0); + cookie.setPath("/"); + return cookie; + } +} diff --git a/src/main/java/com/moabam/global/common/util/DateUtils.java b/src/main/java/com/moabam/global/common/util/DateUtils.java new file mode 100644 index 00000000..33e896fa --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/DateUtils.java @@ -0,0 +1,17 @@ +package com.moabam.global.common.util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class DateUtils { + + private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + public static String format(LocalDateTime dateTime) { + return dateTime.format(formatter); + } +} diff --git a/src/main/java/com/moabam/global/common/util/DynamicQuery.java b/src/main/java/com/moabam/global/common/util/DynamicQuery.java new file mode 100644 index 00000000..47468ee9 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/DynamicQuery.java @@ -0,0 +1,34 @@ +package com.moabam.global.common.util; + +import java.util.Objects; +import java.util.function.Function; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.SimpleExpression; + +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +public class DynamicQuery { + + public static BooleanExpression generateEq(T value, Function function) { + if (Objects.isNull(value)) { + return null; + } + + return function.apply(value); + } + + public static BooleanExpression generateIsNull(Boolean value, T field) { + if (Objects.isNull(value)) { + return null; + } + + if (Boolean.TRUE.equals(value)) { + return field.isNull(); + } + + return field.isNotNull(); + } +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java new file mode 100644 index 00000000..7b62d447 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -0,0 +1,21 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GlobalConstant { + + public static final String BLANK = ""; + public static final String DELIMITER = "/"; + public static final String CHARSET_UTF_8 = ";charset=UTF-8"; + public static final String SPACE = " "; + public static final int MIDNIGHT_HOUR = 0; + public static final int ONE_HOUR = 1; + public static final int HOURS_IN_A_DAY = 24; + public static final int NOT_COMPLETED_RANK = 500; + + public static final int ROOM_FIXED_SEARCH_SIZE = 10; + public static final int LEVEL_DIVISOR = 10; + public static final String IMAGE_EXTENSION = ".png"; +} diff --git a/src/main/java/com/moabam/global/common/util/RandomUtils.java b/src/main/java/com/moabam/global/common/util/RandomUtils.java new file mode 100644 index 00000000..462d6f5a --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/RandomUtils.java @@ -0,0 +1,19 @@ +package com.moabam.global.common.util; + +import java.security.SecureRandom; + +import org.apache.commons.lang3.RandomStringUtils; + +public class RandomUtils { + + public static String randomStringValues() { + return RandomStringUtils.random(6, 0, 0, true, true, null, + new SecureRandom()); + } + + public static String randomNumberValues() { + return RandomStringUtils.random(4, 0, 0, false, true, null, + new SecureRandom()); + } + +} diff --git a/src/main/java/com/moabam/global/common/util/StreamUtils.java b/src/main/java/com/moabam/global/common/util/StreamUtils.java new file mode 100644 index 00000000..2820d908 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/StreamUtils.java @@ -0,0 +1,17 @@ +package com.moabam.global.common.util; + +import java.util.List; +import java.util.function.Function; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class StreamUtils { + + public static List map(List list, Function mapper) { + return list.stream() + .map(mapper) + .toList(); + } +} diff --git a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java new file mode 100644 index 00000000..1d24cdc3 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java @@ -0,0 +1,22 @@ +package com.moabam.global.common.util; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +@Component +@Profile({"dev", "prod"}) +public class SystemClockHolder implements ClockHolder { + + @Override + public LocalDateTime times() { + return LocalDateTime.now(); + } + + @Override + public LocalDate date() { + return LocalDate.now(); + } +} diff --git a/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java b/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java new file mode 100644 index 00000000..52f2cba2 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/UrlSubstringParser.java @@ -0,0 +1,25 @@ +package com.moabam.global.common.util; + +import static com.moabam.global.common.util.GlobalConstant.*; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class UrlSubstringParser { + + public static String parseUrl(String url, String distinctToken) { + + int lastSlashTokenIndex = url.lastIndexOf(DELIMITER); + int distinctTokenIndex = url.indexOf(distinctToken); + + if (lastSlashTokenIndex == -1 || distinctTokenIndex == -1 || lastSlashTokenIndex > distinctTokenIndex) { + throw new BadRequestException(ErrorMessage.INVALID_REQUEST_URL); + } + + return url.substring(lastSlashTokenIndex + 1, distinctTokenIndex); + } +} diff --git a/src/main/java/com/moabam/global/common/util/cookie/CookieDevUtils.java b/src/main/java/com/moabam/global/common/util/cookie/CookieDevUtils.java new file mode 100644 index 00000000..ca08b11f --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/cookie/CookieDevUtils.java @@ -0,0 +1,22 @@ +package com.moabam.global.common.util.cookie; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; + +@Component +@Profile({"dev", "local", "test"}) +public class CookieDevUtils extends CookieUtils { + + protected Cookie detailCookies(String name, String value, long expireTime) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int)expireTime); + cookie.setAttribute("SameSite", "None"); + + return cookie; + } +} diff --git a/src/main/java/com/moabam/global/common/util/cookie/CookieProdUtils.java b/src/main/java/com/moabam/global/common/util/cookie/CookieProdUtils.java new file mode 100644 index 00000000..72ec0dce --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/cookie/CookieProdUtils.java @@ -0,0 +1,22 @@ +package com.moabam.global.common.util.cookie; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import jakarta.servlet.http.Cookie; + +@Component +@Profile({"prod"}) +public class CookieProdUtils extends CookieUtils { + + protected Cookie detailCookies(String name, String value, long expireTime) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int)expireTime); + + return cookie; + } +} + diff --git a/src/main/java/com/moabam/global/common/util/cookie/CookieUtils.java b/src/main/java/com/moabam/global/common/util/cookie/CookieUtils.java new file mode 100644 index 00000000..e0b7cb9e --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/cookie/CookieUtils.java @@ -0,0 +1,22 @@ +package com.moabam.global.common.util.cookie; + +import jakarta.servlet.http.Cookie; + +public abstract class CookieUtils { + + public Cookie tokenCookie(String name, String value, long expireTime) { + return detailCookies(name, value, expireTime); + } + + public Cookie typeCookie(String value, long expireTime) { + return detailCookies("token_type", value, expireTime); + } + + public Cookie deleteCookie(Cookie cookie) { + cookie.setMaxAge(0); + cookie.setPath("/"); + return cookie; + } + + protected abstract Cookie detailCookies(String name, String value, long expireTime); +} diff --git a/src/main/java/com/moabam/global/config/AllowOriginConfig.java b/src/main/java/com/moabam/global/config/AllowOriginConfig.java new file mode 100644 index 00000000..b580a99f --- /dev/null +++ b/src/main/java/com/moabam/global/config/AllowOriginConfig.java @@ -0,0 +1,13 @@ +package com.moabam.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "allows") +public record AllowOriginConfig( + String adminDomain, + String domain, + List origin) { + +} diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java new file mode 100644 index 00000000..85e7f64a --- /dev/null +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -0,0 +1,227 @@ +package com.moabam.global.config; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.ClassPathResource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.moabam.global.error.exception.MoabamException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.annotation.PreDestroy; +import lombok.Builder; +import lombok.extern.slf4j.Slf4j; +import redis.embedded.RedisServer; + +@Slf4j +@Configuration +@Profile("test") +public class EmbeddedRedisConfig { + + private final int redisPort; + private final String redisHost; + + private int availablePort; + private RedisServer redisServer; + + public EmbeddedRedisConfig( + @Value("${spring.data.redis.port}") int redisPort, + @Value("${spring.data.redis.host}") String redisHost + ) { + this.redisPort = redisPort; + this.redisHost = redisHost; + + startRedis(); + } + + @Bean + public RedisConnectionFactory redisConnectionFactory(EmbeddedRedisConfig embeddedRedisConfig) { + return new LettuceConnectionFactory(redisHost, embeddedRedisConfig.getAvailablePort()); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + return redisTemplate; + } + + @Order(2) + @Bean + public ObjectMapper objectRedisMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModules(new JavaTimeModule()); + + return objectMapper; + } + + public void startRedis() { + Os os = Os.createOs(); + availablePort = findPort(os); + + if (os.isMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), availablePort); + } else { + redisServer = RedisServer.builder() + .port(availablePort) + .setting("maxmemory 128M") + .build(); + } + + try { + redisServer.start(); + } catch (Exception e) { + stopRedis(); + throw new MoabamException(e.getMessage()); + } + } + + @PreDestroy + public void stopRedis() { + try { + if (redisServer != null) { + redisServer.stop(); + } + } catch (Exception e) { + throw new MoabamException(e.getMessage()); + } + } + + public int getAvailablePort() { + return availablePort; + } + + private int findPort(Os os) { + if (!isRunning(os.executeCommand(redisPort))) { + return redisPort; + } + + return findAvailablePort(os); + } + + private int findAvailablePort(Os os) { + for (int port = 10000; port <= 65535; port++) { + Process process = os.executeCommand(port); + + if (!isRunning(process)) { + return port; + } + } + + throw new MoabamException(ErrorMessage.NOT_FOUND_AVAILABLE_PORT); + } + + private boolean isRunning(Process process) { + String line; + StringBuilder pidInfo = new StringBuilder(); + + try (BufferedReader input = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + while ((line = input.readLine()) != null) { + pidInfo.append(line); + } + } catch (Exception e) { + throw new MoabamException(ErrorMessage.ERROR_EXECUTING_EMBEDDED_REDIS); + } + + return StringUtils.hasText(pidInfo.toString()); + } + + private File getRedisFileForArcMac() { + try { + return new ClassPathResource("binary/redis/redis-server-arm64").getFile(); + } catch (Exception e) { + throw new MoabamException(e.getMessage()); + } + } + + private static final class Os { + + enum Type { + MAC, + WIN, + LINUX + } + + private final String shellPath; + private final String optionOperator; + private final String command; + private final Type type; + + @Builder + private Os(String shellPath, String optionOperator, String command, Type type) { + this.shellPath = shellPath; + this.optionOperator = optionOperator; + this.command = command; + this.type = type; + } + + public Process executeCommand(int port) { + String osCommand = String.format(this.command, port); + String[] script = {shellPath, optionOperator, osCommand}; + + try { + return Runtime.getRuntime().exec(script); + } catch (IOException e) { + throw new MoabamException(e.getMessage()); + } + } + + public boolean isMac() { + return type == Type.MAC; + } + + public static Os createOs() { + String osArchitecture = System.getProperty("os.arch"); + String osName = System.getProperty("os.name"); + + if (osArchitecture.equals("aarch64") && osName.equals("Mac OS X")) { + return linuxOs(Type.MAC); + } + + if (osArchitecture.equals("amd64") && osName.contains("Windows")) { + return windowOs(); + } + + return linuxOs(Type.LINUX); + } + + // 변경 전 + private static Os linuxOs(Type type) { + return Os.builder() + .shellPath("/bin/sh") + .optionOperator("-c") + .command("netstat -nat | grep LISTEN | grep %d") + .type(type) + .build(); + } + + // 변경 후 + private static Os windowOs() { + return Os.builder() + .shellPath("cmd.exe") + .optionOperator("/c") + .command("netstat -ano | findstr LISTEN | findstr %d") + .type(Type.WIN) + .build(); + } + } +} diff --git a/src/main/java/com/moabam/global/config/FcmConfig.java b/src/main/java/com/moabam/global/config/FcmConfig.java new file mode 100644 index 00000000..6f003820 --- /dev/null +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -0,0 +1,44 @@ +package com.moabam.global.config; + +import java.io.IOException; +import java.io.InputStream; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; + +import com.google.auth.oauth2.GoogleCredentials; +import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseOptions; +import com.google.firebase.messaging.FirebaseMessaging; +import com.moabam.global.error.exception.FcmException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Configuration +public class FcmConfig { + + private static final String FIREBASE_PATH = "config/moabam-firebase.json"; + + @Bean + public FirebaseMessaging firebaseMessaging() { + try (InputStream inputStream = new ClassPathResource(FIREBASE_PATH).getInputStream()) { + GoogleCredentials credentials = GoogleCredentials.fromStream(inputStream); + FirebaseOptions firebaseOptions = FirebaseOptions.builder() + .setCredentials(credentials) + .build(); + + if (FirebaseApp.getApps().isEmpty()) { + FirebaseApp.initializeApp(firebaseOptions); + log.info("======= Firebase init start ======="); + } + + return FirebaseMessaging.getInstance(); + } catch (IOException e) { + log.error("======= firebase moabam error =======\n" + e); + throw new FcmException(ErrorMessage.FAILED_FCM_INIT); + } + } +} diff --git a/src/main/java/com/moabam/global/config/JpaConfig.java b/src/main/java/com/moabam/global/config/JpaConfig.java new file mode 100644 index 00000000..a443a76e --- /dev/null +++ b/src/main/java/com/moabam/global/config/JpaConfig.java @@ -0,0 +1,24 @@ +package com.moabam.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@Configuration +@EnableJpaAuditing +public class JpaConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java new file mode 100644 index 00000000..4884aacb --- /dev/null +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -0,0 +1,34 @@ +package com.moabam.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "oauth2") +public record OAuthConfig( + Provider provider, + Client client +) { + + public record Client( + String provider, + String clientId, + String clientSecret, + String authorizationGrantType, + List scope, + String adminKey + ) { + + } + + public record Provider( + String authorizationUri, + String redirectUri, + String tokenUri, + String tokenInfo, + String unlink, + String adminRedirectUri + ) { + + } +} diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java new file mode 100644 index 00000000..9d017cce --- /dev/null +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -0,0 +1,38 @@ +package com.moabam.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@Profile("!test") +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String redisHost; + + @Value("${spring.data.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + return redisTemplate; + } +} diff --git a/src/main/java/com/moabam/global/config/SlackConfig.java b/src/main/java/com/moabam/global/config/SlackConfig.java new file mode 100644 index 00000000..20ec8805 --- /dev/null +++ b/src/main/java/com/moabam/global/config/SlackConfig.java @@ -0,0 +1,19 @@ +package com.moabam.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import net.gpedro.integrations.slack.SlackApi; + +@Configuration +public class SlackConfig { + + @Value("${webhook.slack.url}") + private String webhookUrl; + + @Bean + public SlackApi slackApi() { + return new SlackApi(webhookUrl); + } +} diff --git a/src/main/java/com/moabam/global/config/TokenConfig.java b/src/main/java/com/moabam/global/config/TokenConfig.java new file mode 100644 index 00000000..4bbe5a1e --- /dev/null +++ b/src/main/java/com/moabam/global/config/TokenConfig.java @@ -0,0 +1,32 @@ +package com.moabam.global.config; + +import java.nio.charset.StandardCharsets; +import java.security.Key; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.jsonwebtoken.security.Keys; +import lombok.Getter; + +@Getter +@ConfigurationProperties(prefix = "token") +public class TokenConfig { + + private final String iss; + private final long accessExpire; + private final long refreshExpire; + private final String secretKey; + private final String adminSecret; + private final Key key; + private final Key adminKey; + + public TokenConfig(String iss, long accessExpire, long refreshExpire, String secretKey, String adminSecret) { + this.iss = iss; + this.accessExpire = accessExpire; + this.refreshExpire = refreshExpire; + this.secretKey = secretKey; + this.adminSecret = adminSecret; + this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + this.adminKey = Keys.hmacShaKeyFor(adminSecret.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/moabam/global/config/TossPaymentConfig.java b/src/main/java/com/moabam/global/config/TossPaymentConfig.java new file mode 100644 index 00000000..e3f2bcca --- /dev/null +++ b/src/main/java/com/moabam/global/config/TossPaymentConfig.java @@ -0,0 +1,11 @@ +package com.moabam.global.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "payment.toss") +public record TossPaymentConfig( + String baseUrl, + String secretKey +) { + +} diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java new file mode 100644 index 00000000..e5971d20 --- /dev/null +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -0,0 +1,53 @@ +package com.moabam.global.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import com.moabam.api.application.auth.mapper.PathMapper; +import com.moabam.global.auth.handler.AuthArgumentResolver; +import com.moabam.global.auth.handler.PathResolver; + +@Configuration +@EnableScheduling +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(handlerMethodArgumentResolver()); + } + + @Bean + public HandlerMethodArgumentResolver handlerMethodArgumentResolver() { + return new AuthArgumentResolver(); + } + + @Bean + public PathResolver pathResolver() { + PathResolver.Paths path = PathResolver.Paths.builder() + .permitAll(List.of( + PathMapper.parsePath("/"), + PathMapper.pathWithMethod("/members", List.of(HttpMethod.POST)), + PathMapper.pathWithMethod("/members/login/oauth", List.of(HttpMethod.GET)), + PathMapper.parsePath("/members/login/*/oauth"), + PathMapper.parsePath("/admins/login/*/oauth"), + PathMapper.parsePath("/css/*"), + PathMapper.parsePath("/js/*"), + PathMapper.parsePath("/images/*"), + PathMapper.parsePath("/webjars/*"), + PathMapper.parsePath("/favicon/*"), + PathMapper.parsePath("/*/icon-*"), + PathMapper.parsePath("/favicon.ico"), + PathMapper.parsePath("/v3/api-docs"), + PathMapper.parsePath("/swagger*/**"), + PathMapper.pathWithMethod("/serverTime", List.of(HttpMethod.GET)))) + .build(); + + return new PathResolver(path); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/BadRequestException.java b/src/main/java/com/moabam/global/error/exception/BadRequestException.java new file mode 100644 index 00000000..e0826af2 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/BadRequestException.java @@ -0,0 +1,10 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class BadRequestException extends MoabamException { + + public BadRequestException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/ConflictException.java b/src/main/java/com/moabam/global/error/exception/ConflictException.java new file mode 100644 index 00000000..fe756197 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/ConflictException.java @@ -0,0 +1,10 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class ConflictException extends MoabamException { + + public ConflictException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/FcmException.java b/src/main/java/com/moabam/global/error/exception/FcmException.java new file mode 100644 index 00000000..25c9fa48 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/FcmException.java @@ -0,0 +1,10 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class FcmException extends MoabamException { + + public FcmException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/ForbiddenException.java b/src/main/java/com/moabam/global/error/exception/ForbiddenException.java new file mode 100644 index 00000000..05ca2c3c --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/ForbiddenException.java @@ -0,0 +1,10 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class ForbiddenException extends MoabamException { + + public ForbiddenException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/MoabamException.java b/src/main/java/com/moabam/global/error/exception/MoabamException.java new file mode 100644 index 00000000..c7988bf3 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/MoabamException.java @@ -0,0 +1,18 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class MoabamException extends RuntimeException { + + private final ErrorMessage errorMessage; + + public MoabamException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public MoabamException(String message) { + super(message); + this.errorMessage = ErrorMessage.FAILED_MOABAM; + } +} diff --git a/src/main/java/com/moabam/global/error/exception/NotFoundException.java b/src/main/java/com/moabam/global/error/exception/NotFoundException.java new file mode 100644 index 00000000..08273e0e --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/NotFoundException.java @@ -0,0 +1,13 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +import lombok.Getter; + +@Getter +public class NotFoundException extends MoabamException { + + public NotFoundException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/TossPaymentException.java b/src/main/java/com/moabam/global/error/exception/TossPaymentException.java new file mode 100644 index 00000000..2b86b1d4 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/TossPaymentException.java @@ -0,0 +1,8 @@ +package com.moabam.global.error.exception; + +public class TossPaymentException extends MoabamException { + + public TossPaymentException(String errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/exception/UnauthorizedException.java b/src/main/java/com/moabam/global/error/exception/UnauthorizedException.java new file mode 100644 index 00000000..9f99a3e8 --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/UnauthorizedException.java @@ -0,0 +1,10 @@ +package com.moabam.global.error.exception; + +import com.moabam.global.error.model.ErrorMessage; + +public class UnauthorizedException extends MoabamException { + + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage); + } +} diff --git a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java new file mode 100644 index 00000000..766a7e01 --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -0,0 +1,114 @@ +package com.moabam.global.error.handler; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import org.springframework.web.multipart.MaxUploadSizeExceededException; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.FcmException; +import com.moabam.global.error.exception.ForbiddenException; +import com.moabam.global.error.exception.MoabamException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.exception.TossPaymentException; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NotFoundException.class) + protected ErrorResponse handleNotFoundException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + protected ErrorResponse handleUnauthorizedException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(ForbiddenException.class) + protected ErrorResponse handleForbiddenException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler(ConflictException.class) + protected ErrorResponse handleConflictException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BadRequestException.class) + protected ErrorResponse handleBadRequestException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler({ + FcmException.class, + TossPaymentException.class + }) + protected ErrorResponse handleFcmException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MoabamException.class) + protected ErrorResponse handleMoabamException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(NullPointerException.class) + protected ErrorResponse handleNullPointerException(NullPointerException exception) { + return new ErrorResponse(exception.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { + List fieldErrors = exception.getBindingResult().getFieldErrors(); + Map validation = new HashMap<>(); + + for (FieldError fieldError : fieldErrors) { + validation.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + return new ErrorResponse(INVALID_REQUEST_FIELD.getMessage(), validation); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + protected ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) { + String typeName = Optional.ofNullable(exception.getRequiredType()) + .map(Class::getSimpleName) + .orElse(""); + String message = String.format(INVALID_REQUEST_VALUE_TYPE_FORMAT.getMessage(), exception.getValue(), typeName); + + return new ErrorResponse(message, null); + } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + protected ErrorResponse handleMaxSizeException(MaxUploadSizeExceededException exception) { + String message = String.format(S3_INVALID_IMAGE_SIZE.getMessage()); + + return new ErrorResponse(message, null); + } +} diff --git a/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java new file mode 100644 index 00000000..d0708d97 --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java @@ -0,0 +1,62 @@ +package com.moabam.global.error.handler; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; + +import org.springframework.http.HttpStatusCode; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.client.ResponseErrorHandler; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +@Component +public class RestTemplateResponseHandler implements ResponseErrorHandler { + + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + try { + return response.getStatusCode().isError(); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + @Override + public void handleError(ClientHttpResponse response) { + try { + String errorMessage = parseErrorMessage(response); + HttpStatusCode statusCode = response.getStatusCode(); + + validResponse(statusCode); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + private String parseErrorMessage(ClientHttpResponse response) throws IOException { + BufferedReader errorMessage = new BufferedReader(new InputStreamReader(response.getBody())); + + String line = errorMessage.readLine(); + StringBuilder sb = new StringBuilder(); + + while (line != null) { + sb.append(line).append("\n"); + line = errorMessage.readLine(); + } + + return sb.toString(); + } + + private void validResponse(HttpStatusCode statusCode) { + if (statusCode.is5xxServerError()) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + + if (statusCode.is4xxClientError()) { + throw new BadRequestException(ErrorMessage.INVALID_REQUEST_FIELD); + } + } +} diff --git a/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java new file mode 100644 index 00000000..b144c62b --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java @@ -0,0 +1,29 @@ +package com.moabam.global.error.handler; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.moabam.api.infrastructure.slack.SlackService; +import com.moabam.global.error.model.ErrorResponse; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestControllerAdvice +@Profile({"dev", "prod"}) +@RequiredArgsConstructor +public class SlackExceptionHandler { + + private final SlackService slackService; + + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + protected ErrorResponse handleException(HttpServletRequest request, Exception exception) throws Exception { + slackService.send(request, exception); + + return new ErrorResponse(exception.getMessage(), null); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java new file mode 100644 index 00000000..62428059 --- /dev/null +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -0,0 +1,105 @@ +package com.moabam.global.error.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + + FAILED_MOABAM("모아밤 서버 실행 중 오류가 발생했습니다."), + INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + INVALID_REQUEST_VALUE_TYPE_FORMAT("'%s' 값은 유효한 %s 값이 아닙니다."), + NOT_FOUND_AVAILABLE_PORT("사용 가능한 포트를 찾을 수 없습니다. (10000 ~ 65535)"), + ERROR_EXECUTING_EMBEDDED_REDIS("Embedded Redis 실행 중 오류가 발생했습니다."), + INVALID_REQUEST_ROLE("회원은 회원에, 어드민은 어드민에 연결해야 합니다."), + + REPORT_REQUEST_ERROR("신고 요청하고자 하는 방이나 대상이 존재하지 않습니다."), + + ROOM_NOT_FOUND("존재하지 않는 방 입니다."), + ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), + ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), + ROOM_EXIT_MANAGER_FAIL("인원수가 2명 이상일 때는 방장을 위임해야 합니다."), + PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), + WRONG_ROOM_PASSWORD("방의 비밀번호가 일치하지 않습니다."), + ROOM_MAX_USER_REACHED("방의 인원수가 찼습니다."), + ROOM_DETAILS_ERROR("방 정보를 불러오는데 실패했습니다."), + ROUTINE_LENGTH_ERROR("루틴의 길이가 잘못 되었습니다."), + DUPLICATED_DAILY_MEMBER_CERTIFICATION("이미 오늘의 인증을 완료하였습니다."), + ROUTINE_NOT_FOUND("루틴을 찾을 수 없습니다"), + INVALID_REQUEST_URL("잘못된 URL 요청입니다."), + INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), + CERTIFICATION_NOT_FOUND("인증 정보가 없습니다."), + NEED_TO_EXIT_ALL_ROOMS("모든 방에서 나가야 회원 탈퇴가 가능합니다."), + PARTICIPANT_DEPORT_ERROR("방장은 자신을 추방할 수 없습니다."), + IMAGE_CONVERT_FAIL("이미지 변환을 실패했습니다."), + UNAVAILABLE_TO_CHANGE_CERTIFY_TIME("이미 한명 이상이 인증을 하면 인증 시간을 바꿀 수 없습니다."), + CERTIFIED_ROOM_EXIT_FAILED("오늘 인증한 방은 나갈 수 없습니다."), + ROOM_ENTER_FAILED("해당 방의 인증 시간에는 입장할 수 없습니다."), + + LOGIN_FAILED("로그인에 실패했습니다."), + LOGIN_FAILED_ADMIN_KEY("어드민키가 달라요"), + REQUEST_FAILED("네트워크 접근 실패입니다."), + TOKEN_TYPE_FAILED("토큰 타일이 일치하지 않습니다."), + GRANT_FAILED("인가 코드 실패"), + TOKEN_EXPIRE("토큰이 만료되었습니다."), + AUTHENTICATE_FAIL("인증 실패"), + TOKEN_NOT_FOUND("토큰이 존재하지 않습니다."), + COOKIE_NOT_FOUND("쿠키가 없습니다"), + MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), + MEMBER_NOT_FOUND_BY_MANAGER_OR_NULL("방의 매니저거나 회원이 존재하지 않습니다."), + MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), + UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS("카카오 연결 요청 실패로 Rollback하였습니다."), + NICKNAME_CONFLICT("이미 존재하는 닉네임입니다."), + + BASIC_SKIN_NOT_FOUND("기본 스킨 오류 발생, 관리자에게 문의하세요"), + INVALID_DEFAULT_SKIN_SIZE("기본 스킨은 2개여야 합니다. 관리자에게 문의하세요"), + SKIN_TYPE_NOT_FOUND("스킨 타입이 없습니다. 관리자에게 문의하세요"), + + BUG_NOT_ENOUGH("보유한 벌레가 부족합니다."), + + ITEM_NOT_FOUND("존재하지 않는 아이템입니다."), + ITEM_UNLOCK_LEVEL_HIGH("아이템 해금 레벨이 높습니다."), + ITEM_NOT_PURCHASABLE_BY_BUG_TYPE("해당 벌레 타입으로는 구매할 수 없는 아이템입니다."), + INVENTORY_NOT_FOUND("구매하지 않은 아이템은 적용할 수 없습니다."), + DEFAULT_INVENTORY_NOT_FOUND("현재 적용된 아이템이 없습니다."), + INVENTORY_CONFLICT("이미 구매한 아이템입니다."), + + INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), + INVALID_PRICE("가격은 0 이상이어야 합니다."), + INVALID_QUANTITY("수량은 1 이상이어야 합니다."), + INVALID_LEVEL("레벨은 1 이상이어야 합니다."), + INVALID_PAYMENT_AMOUNT("결제 금액은 0 이상이어야 합니다."), + + PRODUCT_NOT_FOUND("존재하지 않는 상품입니다."), + + PAYMENT_NOT_FOUND("존재하지 않는 결제 정보입니다."), + INVALID_MEMBER_PAYMENT("해당 회원의 결제 정보가 아닙니다."), + INVALID_PAYMENT_INFO("결제 정보가 일치하지 않습니다."), + + FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), + NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), + CONFLICT_KNOCK("이미 콕 알림을 보낸 대상입니다."), + + INVALID_COUPON_POINT("쿠폰의 보너스 포인트는 0 이상이어야 합니다."), + INVALID_COUPON_STOCK("쿠폰의 재고는 0 이상이어야 합니다."), + INVALID_COUPON_STOCK_END("쿠폰 발급 선착순이 마감되었습니다."), + INVALID_COUPON_START_AT_PERIOD("쿠폰 발급 시작 날짜는 현재 날짜보다 이전이거나 같을 수 없습니다."), + INVALID_COUPON_OPEN_AT_PERIOD("쿠폰 정보 오픈 날짜는 시작 날짜보다 이전이여야 합니다."), + INVALID_COUPON_PERIOD("쿠폰 발급 가능 기간이 아닙니다."), + INVALID_DISCOUNT_COUPON("할인 쿠폰은 결제 시, 사용할 수 있습니다."), + INVALID_BUG_COUPON("벌레 쿠폰은 보관함에서 사용할 수 있습니다."), + CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), + CONFLICT_COUPON_START_AT("쿠폰 발급 가능 날짜가 중복되었습니다."), + CONFLICT_COUPON_ISSUE("이미 쿠폰 발급에 성공했습니다!"), + NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), + NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), + NOT_FOUND_COUPON_WALLET("보유하지 않은 쿠폰입니다."), + + S3_UPLOAD_FAIL("S3 업로드를 실패했습니다."), + S3_INVALID_IMAGE("올바른 이미지(파일) 형식이 아닙니다."), + S3_INVALID_IMAGE_SIZE("파일의 용량이 너무 큽니다."), + S3_RESIZE_ERROR("이미지 리사이징에서 에러가 발생했습니다."); + + private final String message; +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorResponse.java b/src/main/java/com/moabam/global/error/model/ErrorResponse.java new file mode 100644 index 00000000..b4349e9c --- /dev/null +++ b/src/main/java/com/moabam/global/error/model/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.moabam.global.error.model; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; + +public record ErrorResponse( + String message, + @JsonInclude(JsonInclude.Include.NON_EMPTY) Map validation +) { +} diff --git a/src/main/resources/binary/redis/redis-server-arm64 b/src/main/resources/binary/redis/redis-server-arm64 new file mode 100755 index 00000000..c3090073 Binary files /dev/null and b/src/main/resources/binary/redis/redis-server-arm64 differ diff --git a/src/main/resources/config b/src/main/resources/config index 8bc59e64..565a0f7b 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 8bc59e6455ce1220e00acf676849951cbd935373 +Subproject commit 565a0f7b48e49dbaf78c798220fe712200c17ee5 diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 00000000..cca2fc4f --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,35 @@ + + + + + + ${SLACK_WEBHOOK_URL} + + + 에러 로그를 수집했습니다 🚓 + ✅ Timestamp\n%d{yyyy-MM-dd HH:mm:ss}\n 📍 Error Message\n%msg + + true + + + + + + + ERROR + + + + + + [%d{yyyy-MM-dd HH:mm:ss}:%-3relative][%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html new file mode 100644 index 00000000..8041f735 --- /dev/null +++ b/src/main/resources/static/docs/coupon.html @@ -0,0 +1,723 @@ + + + + + + + +쿠폰(Coupon) + + + + + +
+
+

쿠폰(Coupon)

+
+
+
+
쿠폰에 대해 생성/삭제/조회/발급/사용 기능을 제공합니다.
+
+
+
+
+

쿠폰 생성

+
+
+
관리자가 쿠폰을 생성합니다.
+
+
+

요청

+
+
+
POST /admins/coupons HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 186
+Host: localhost:8080
+
+{
+  "name" : "couponName",
+  "description" : "coupon description",
+  "type" : "황금",
+  "point" : 10,
+  "maxCount" : 10,
+  "startAt" : "2023-02-01",
+  "openAt" : "2023-01-01"
+}
+
+
+

응답

+
+
+
HTTP/1.1 201 Created
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+
+
+
+
+
+

쿠폰 삭제

+
+
+
관리자가 쿠폰 ID와 일치하는 쿠폰을 삭제합니다.
+
+
+

요청

+
+
+
DELETE /admins/coupons/36 HTTP/1.1
+Host: localhost:8080
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+
+
+
+
+
+

특정 쿠폰 조회

+
+
+
관리자 혹은 사용자가 특정 ID와 일치하는 쿠폰을 조회합니다.
+
+
+
+

요청

+
+
+
GET /coupons/24 HTTP/1.1
+Host: localhost:8080
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+Content-Type: application/json
+Content-Length: 202
+
+{
+  "id" : 24,
+  "adminId" : 1,
+  "name" : "couponName",
+  "description" : "",
+  "point" : 10,
+  "maxCount" : 100,
+  "type" : "MORNING",
+  "startAt" : "2023-02-01",
+  "openAt" : "2023-01-01"
+}
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

+
+
+
관리자 혹은 사용자가 날짜 상태에 따라 쿠폰들을 조회합니다.
+
+
+
+

요청

+
+
+
POST /coupons/search HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 44
+Host: localhost:8080
+
+{
+  "opened" : false,
+  "ended" : false
+}
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+Content-Type: application/json
+Content-Length: 203
+
+[ {
+  "id" : 25,
+  "adminId" : 1,
+  "name" : "coupon1",
+  "description" : "",
+  "point" : 10,
+  "maxCount" : 100,
+  "type" : "MORNING",
+  "startAt" : "2023-03-01",
+  "openAt" : "2023-01-01"
+} ]
+
+
+
+
+
+
+

특정 쿠폰에 대해 발급

+
+
+
사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다.
+
+
+
+

요청

+
+
+
POST /coupons HTTP/1.1
+Content-Type: application/x-www-form-urlencoded
+Host: localhost:8080
+Content-Length: 21
+
+couponName=couponName
+
+
+

응답

+
+
+
HTTP/1.1 409 Conflict
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+Content-Type: application/json
+Content-Length: 65
+
+{
+  "message" : "이미 쿠폰 발급에 성공했습니다!"
+}
+
+
+
+
+
+
+

특정 사용자의 쿠폰 보관함을 조회

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다.
+
+
+
+

요청

+
+
+
GET /my-coupons HTTP/1.1
+Host: localhost:8080
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+Content-Type: application/json
+Content-Length: 3
+
+[ ]
+
+
+
+
+
+
+

쿠폰을 사용

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+

요청

+
+
+
POST /my-coupons/8 HTTP/1.1
+Host: localhost:8080
+Content-Type: application/x-www-form-urlencoded
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html new file mode 100644 index 00000000..215db37a --- /dev/null +++ b/src/main/resources/static/docs/index.html @@ -0,0 +1,630 @@ + + + + + + + +MOABAM API 문서 + + + + + + + +
+
+

1. 개요

+
+
+

이 API 문서는 'MOABAM' 프로젝트의 산출물입니다.

+
+
+

1.1. API 서버 경로

+ +++++ + + + + + + + + + + + + + + + + + +

환경

DNS

비고

개발(dev)

dev.moabam.com

운영(prod)

www.moabam.com

+
+ + + + + +
+ + +
+

해당 프로젝트 API 문서는 [특이사항]입니다.

+
+
+
+
+ + + + + +
+ + +
+

해당 프로젝트 API 문서는 [주의사항]입니다.

+
+
+
+
+
+

1.2. 응답형식

+
+

프로젝트는 다음과 같은 응답형식을 제공합니다.

+
+
+

1.2.1. 정상(2XX)

+ ++++ + + + + + + + + + + + + +
응답데이터가 없는 경우응답데이터가 있는 경우
+
+
{
+
+}
+
+
+
+
{
+  "name": "Hong-Dosan"
+}
+
+
+
+
+

1.2.2. 상태코드(HttpStatus)

+
+

응답시 다음과 같은 응답상태 헤더, 응답코드 및 응답메시지를 제공합니다.

+
+ ++++ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
HttpStatus설명

OK(200)

정상 응답

CREATED(201)

새로운 리소스 생성

BAD_REQUEST(400)

요청값 누락, 잘못된 기입

UNAUTHORIZED(401)

비인증 요청

NOT_FOUND(404)

요청값 누락, 잘못된 기입, 비인가 접속 등

CONFLICT(409)

요청값 중복

INTERNAL_SERVER_ERROR(500)

알 수 없는 서버 에러가 발생했습니다. 관리자에게 문의하세요.

+
+
+
+
+
+ + + + + \ No newline at end of file diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html new file mode 100644 index 00000000..4cd1aa89 --- /dev/null +++ b/src/main/resources/static/docs/notification.html @@ -0,0 +1,520 @@ + + + + + + + +알림(Notification) + + + + + +
+
+

알림(Notification)

+
+
+
+
콕 찌르기 알림, FCM Token 저장 기능을 제공합니다.
+
+
+
+

콕 찌르기 알림

+
+
+
1) 특정 방의 사용자가 다른 사용자를 콕 찌릅니다.
+2) 서버에서 콕 찌를 대상의 FCM Token 여부를 검증합니다.
+3) Firebase 서버에 FCM Push Messaing 알림을 비동기로 요청합니다.
+4) Firebase 서버에서 FCM Token으로 식별된 기기에 알림을 보냅니다.
+
+
+

요청

+
+
+
GET /notifications/rooms/1/members/2 HTTP/1.1
+Host: localhost:8080
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+
+
+
+
+

FCM TOKEN 저장

+
+
+
1) 특정 사용자의 FCM-TOKEN을 받아서 REDIS DB에 저장합니다.
+
+
+

요청

+
+
+
POST /notifications HTTP/1.1
+Content-Type: application/x-www-form-urlencoded
+Host: localhost:8080
+Content-Length: 18
+
+fcmToken=FCM-TOKEN
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin: http://localhost:8080
+Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
+Access-Control-Allow-Headers: Origin, Accept, Access-Control-Request-Method, Access-Control-Request-Headers, X-Requested-With,Content-Type, Referer
+Access-Control-Allow-Credentials: true
+Access-Control-Max-Age: 3600
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/test/java/com/moabam/MoabamServerApplicationTests.java b/src/test/java/com/moabam/MoabamServerApplicationTests.java index ec2bac91..3eabd1b5 100644 --- a/src/test/java/com/moabam/MoabamServerApplicationTests.java +++ b/src/test/java/com/moabam/MoabamServerApplicationTests.java @@ -1,13 +1,8 @@ package com.moabam; -import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest class MoabamServerApplicationTests { - @Test - void contextLoads() { - } - } diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java new file mode 100644 index 00000000..75f4c5a9 --- /dev/null +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -0,0 +1,352 @@ +package com.moabam.api.application.auth; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.moabam.admin.application.admin.AdminService; +import com.moabam.api.application.auth.mapper.AuthorizationMapper; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.auth.repository.TokenRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.auth.AuthorizationCodeRequest; +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenRequest; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.common.util.CookieUtils; +import com.moabam.global.config.AllowOriginConfig; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.TokenSaveValueFixture; + +import jakarta.servlet.http.Cookie; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class AuthorizationServiceTest { + + @InjectMocks + AuthorizationService authorizationService; + + @Mock + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + + @Mock + MemberService memberService; + + @Mock + AdminService adminService; + + @Mock + JwtProviderService jwtProviderService; + + @Mock + FcmService fcmService; + + @Mock + TokenRepository tokenRepository; + + AllowOriginConfig allowOriginsConfig; + OAuthConfig oauthConfig; + TokenConfig tokenConfig; + AuthorizationService noPropertyService; + OAuthConfig noOAuthConfig; + String domain = "Test"; + + @BeforeEach + public void initParams() { + String secretKey = "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"; + String adminKey = "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"; + + allowOriginsConfig = new AllowOriginConfig(domain, domain, List.of("test", "test")); + ReflectionTestUtils.setField(authorizationService, "allowOriginsConfig", allowOriginsConfig); + tokenConfig = new TokenConfig(null, 100000, 150000, secretKey, adminKey); + ReflectionTestUtils.setField(authorizationService, "tokenConfig", tokenConfig); + + oauthConfig = new OAuthConfig( + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", + "http://tokenInfo/url", "https://deleteRequest/url", "https://adminRequest/url"), + new OAuthConfig.Client("provider", "testtestetsttest", "testtesttest", "authorization_code", + List.of("profile_nickname", "profile_image"), "adminKey")); + ReflectionTestUtils.setField(authorizationService, "oAuthConfig", oauthConfig); + + noOAuthConfig = new OAuthConfig( + new OAuthConfig.Provider(null, null, null, null, null, null), + new OAuthConfig.Client(null, null, null, null, null, null)); + noPropertyService = new AuthorizationService(fcmService, noOAuthConfig, tokenConfig, + oAuth2AuthorizationServerRequestService, memberService, adminService, + jwtProviderService, tokenRepository, allowOriginsConfig); + } + + @DisplayName("인가코드 URI 생성 매퍼 실패") + @Test + void authorization_code_request_mapping_fail() { + // When + Then + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationCodeRequest(noOAuthConfig)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("인가코드 URI 생성 매퍼 성공") + @Test + void authorization_code_request_mapping_success() { + // Given + AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oauthConfig); + + // When + Then + assertThat(authorizationCodeRequest).isNotNull(); + assertAll(() -> assertThat(authorizationCodeRequest.clientId()).isEqualTo(oauthConfig.client().clientId()), + () -> assertThat(authorizationCodeRequest.redirectUri()).isEqualTo(oauthConfig.provider().redirectUri())); + } + + @DisplayName("redirect 로그인페이지 성공") + @Test + void redirect_loginPage_success() { + // given + MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); + + // when + authorizationService.redirectToLoginPage(mockHttpServletResponse); + + // then + verify(oAuth2AuthorizationServerRequestService).loginRequest(eq(mockHttpServletResponse), anyString()); + } + + @DisplayName("인가코드 반환 실패") + @Test + void authorization_grant_fail() { + // Given + AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse(null, "error", + "errorDescription", null); + + // When + Then + assertThatThrownBy(() -> authorizationService.requestToken(authorizationCodeResponse)).isInstanceOf( + BadRequestException.class).hasMessage(ErrorMessage.GRANT_FAILED.getMessage()); + } + + @DisplayName("인가코드 반환 성공") + @Test + void authorization_grant_success() { + // Given + AuthorizationCodeResponse authorizationCodeResponse = + new AuthorizationCodeResponse("test", null, null, null); + AuthorizationTokenResponse authorizationTokenResponse = + AuthorizationResponseFixture.authorizationTokenResponse(); + + // When + when(oAuth2AuthorizationServerRequestService.requestAuthorizationServer(anyString(), any())).thenReturn( + new ResponseEntity<>(authorizationTokenResponse, HttpStatus.OK)); + + // When + Then + assertThatNoException().isThrownBy(() -> authorizationService.requestToken(authorizationCodeResponse)); + } + + @DisplayName("토큰 요청 매퍼 실패 - code null") + @Test + void token_request_mapping_failBy_code() { + // When + Then + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(oauthConfig, + null, oauthConfig.provider().redirectUri())) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("토큰 요청 매퍼 실패 - config 에러") + @Test + void token_request_mapping_failBy_config() { + // When + Then + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test", + oauthConfig.provider().redirectUri())) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("토큰 요청 매퍼 성공") + @Test + void token_request_mapping_success() { + // Given + String code = "Test"; + AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( + oauthConfig, code, oauthConfig.provider().redirectUri()); + + // When + Then + assertThat(authorizationTokenRequest).isNotNull(); + assertAll(() -> assertThat(authorizationTokenRequest.clientId()).isEqualTo(oauthConfig.client().clientId()), + () -> assertThat(authorizationTokenRequest.redirectUri()).isEqualTo(oauthConfig.provider().redirectUri()), + () -> assertThat(authorizationTokenRequest.code()).isEqualTo(code)); + } + + @DisplayName("토큰 변경 성공") + @Test + void generate_token() { + // Given + AuthorizationTokenResponse tokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); + AuthorizationTokenInfoResponse tokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + + // When + when(oAuth2AuthorizationServerRequestService.tokenInfoRequest(any(String.class), + eq("Bearer " + tokenResponse.accessToken()))).thenReturn( + new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); + + // Then + assertThatNoException().isThrownBy(() -> authorizationService.requestTokenInfo(tokenResponse)); + } + + @DisplayName("회원 가입 및 로그인 성공 테스트") + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void signUp_success(boolean isSignUp) { + // given + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + LoginResponse loginResponse = LoginResponse.builder() + .publicClaim(PublicClaim.builder().id(1L).nickname("nickname").role(Role.USER).build()) + .isSignUp(isSignUp) + .build(); + + willReturn(loginResponse).given(memberService).login(authorizationTokenInfoResponse); + + // when + LoginResponse result = authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + + // then + assertThat(loginResponse).isEqualTo(result); + + Cookie tokenType = httpServletResponse.getCookie("token_type"); + assertThat(tokenType).isNotNull(); + assertThat(tokenType.getValue()).isEqualTo("Bearer"); + + Cookie accessCookie = httpServletResponse.getCookie("access_token"); + assertThat(accessCookie).isNotNull(); + assertAll(() -> assertThat(accessCookie.getSecure()).isTrue(), + () -> assertThat(accessCookie.isHttpOnly()).isTrue(), + () -> assertThat(accessCookie.getPath()).isEqualTo("/")); + + Cookie refreshCookie = httpServletResponse.getCookie("refresh_token"); + assertThat(refreshCookie).isNotNull(); + assertAll(() -> assertThat(refreshCookie.getSecure()).isTrue(), + () -> assertThat(refreshCookie.isHttpOnly()).isTrue(), + () -> assertThat(refreshCookie.getPath()).isEqualTo("/")); + } + + @DisplayName("토큰 redis 검증") + @Test + void valid_token_in_redis() { + // Given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L, Role.USER); + + // When + Then + assertThatNoException().isThrownBy(() -> + authorizationService.validTokenPair(1L, "token", Role.USER)); + } + + @DisplayName("이전 토큰과 동일한지 검증") + @Test + void valid_token_failby_notEquals_token() { + // Given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L, Role.USER); + + // When + Then + assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "oldToken", Role.USER)).isInstanceOf( + UnauthorizedException.class).hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + verify(tokenRepository).delete(1L, Role.USER); + } + + @DisplayName("토큰 삭제 성공") + @Test + void error_with_expire_token(@WithMember AuthMember authMember) { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + httpServletRequest.setCookies( + CookieUtils.tokenCookie("access_token", "value", 100000, domain), + CookieUtils.tokenCookie("refresh_token", "value", 100000, domain), + CookieUtils.typeCookie("Bearer", 100000, domain)); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + // When + authorizationService.logout(authMember, httpServletRequest, httpServletResponse); + Cookie cookie = httpServletResponse.getCookie("access_token"); + + // Then + assertThat(cookie).isNotNull(); + assertThat(cookie.getMaxAge()).isZero(); + assertThat(cookie.getValue()).isEqualTo("value"); + + verify(tokenRepository).delete(authMember.id(), Role.USER); + } + + @DisplayName("토큰 없어서 삭제 실패") + @Test + void token_null_delete_fail(@WithMember AuthMember authMember) { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + // When + authorizationService.logout(authMember, httpServletRequest, httpServletResponse); + + // Then + assertThat(httpServletResponse.getCookies()).isEmpty(); + } + + @DisplayName("회원 탈퇴 요청 성공") + @Test + void unlink_success(@WithMember AuthMember authMember) { + // given + Member member = MemberFixture.member(); + + given(memberService.findMember(any())).willReturn(MemberFixture.member()); + doNothing().when(oAuth2AuthorizationServerRequestService) + .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); + + // When + Then + assertThatNoException().isThrownBy(() -> authorizationService.unLinkMember(authMember)); + } + + @DisplayName("회원이 없어서 찾기 실패") + @Test + void unlink_failBy_find_Member(@WithMember AuthMember authMember) { + // Given + When + willThrow(new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)) + .given(memberService) + .validateMemberToDelete(authMember.id()); + + assertThatThrownBy(() -> authorizationService.unLinkMember(authMember)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java new file mode 100644 index 00000000..37149fb2 --- /dev/null +++ b/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java @@ -0,0 +1,145 @@ +package com.moabam.api.application.auth; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Base64; +import java.util.Date; + +import org.assertj.core.api.Assertions; +import org.json.JSONObject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.config.TokenConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.support.fixture.PublicClaimFixture; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationServiceTest { + + String originIss = "PARK"; + String originSecretKey = "testestestestestestestestestesttestestestestestestestestestest"; + String adminKey = "testestestestestestestestestesttestestestestestestestestestest"; + long originId = 1L; + long originAccessExpire = 100000; + long originRefreshExpire = 150000; + + TokenConfig tokenConfig; + JwtAuthenticationService jwtAuthenticationService; + JwtProviderService jwtProviderService; + + @BeforeEach + void initConfig() { + tokenConfig = new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey, adminKey); + jwtProviderService = new JwtProviderService(tokenConfig); + jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + } + + @DisplayName("토큰 인증 성공 테스트") + @Test + void token_authentication_success() { + // given + String token = jwtProviderService.provideAccessToken(PublicClaimFixture.publicClaim()); + + // when, then + assertThatNoException().isThrownBy(() -> + jwtAuthenticationService.isTokenExpire(token, Role.USER)); + } + + @DisplayName("토큰 인증 시간 만료 테스트") + @Test + void token_authentication_time_expire() { + // Given + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + TokenConfig tokenConfig = new TokenConfig(originIss, 0, 0, originSecretKey, adminKey); + JwtAuthenticationService jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + String token = jwtProviderService.provideAccessToken(publicClaim); + + // When + assertThatNoException().isThrownBy(() -> { + boolean result = jwtAuthenticationService.isTokenExpire(token, Role.USER); + + // Then + assertThat(result).isTrue(); + }); + } + + @DisplayName("토큰의 payload 변조되어 인증 실패") + @Test + void token_authenticate_failBy_payload() { + // Given + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + String token = jwtProviderService.provideAccessToken(publicClaim); + String[] parts = token.split("\\."); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject tokenJson = new JSONObject(claims); + + // When + tokenJson.put("id", "2"); + + claims = tokenJson.toString(); + String newToken = String.join(".", parts[0], + Base64.getEncoder().encodeToString(claims.getBytes()), + parts[2]); + + // Then + Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(newToken, Role.USER)) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("토큰 위조 값 검증 테스트") + @Test + void token_authenticate_failBy_key() { + // Givne + String fakeKey = "fakefakefakefakefakefakefakefakefakefakefakefake"; + Key key = Keys.hmacShaKeyFor(fakeKey.getBytes(StandardCharsets.UTF_8)); + + Date now = new Date(); + String token = Jwts.builder() + .setHeaderParam("alg", "HS256") + .setHeaderParam("typ", "JWT") + .setIssuer(originIss) + .setIssuedAt(now) + .setExpiration(new Date(now.getTime() + originAccessExpire)) + .claim("id", 5L) + .signWith(key, SignatureAlgorithm.HS256) + .compact(); + + // When + Then + assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(token, Role.USER)) + .isExactlyInstanceOf(UnauthorizedException.class); + } + + @DisplayName("토큰을 PublicClaim으로 변환 성공") + @Test + void token_parse_to_public_claim() { + // given + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + String token = jwtProviderService.provideAccessToken(publicClaim); + + // when + PublicClaim parsedClaim = jwtAuthenticationService.parseClaim(token); + + // then + assertAll( + () -> assertThat(publicClaim.id()).isEqualTo(parsedClaim.id()), + () -> assertThat(publicClaim.nickname()).isEqualTo(parsedClaim.nickname()), + () -> assertThat(publicClaim.role()).isEqualTo(parsedClaim.role()) + ); + } +} diff --git a/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java new file mode 100644 index 00000000..4a85f46a --- /dev/null +++ b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java @@ -0,0 +1,168 @@ +package com.moabam.api.application.auth; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Base64; + +import org.json.JSONException; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.config.TokenConfig; +import com.moabam.support.fixture.PublicClaimFixture; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; + +class JwtProviderServiceTest { + + String iss = "PARK"; + String secretKey = "testestestestestestestestestesttestestestestestestestestestest"; + String adminKey = "testestestestestestestestestesttestestestestestestestestestest"; + long id = 1L; + + @DisplayName("access 토큰 생성 성공") + @Test + void create_access_token_success() throws JSONException { + // given + long accessExpire = 10000L; + + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey, adminKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + // when + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + + String[] parts = accessToken.split("\\."); + String headers = new String(Base64.getDecoder().decode(parts[0])); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject headersJson = new JSONObject(headers); + JSONObject claimsJson = new JSONObject(claims); + + // then + assertAll( + () -> assertThat(headersJson.get("alg")).isEqualTo("HS256"), + () -> assertThat(headersJson.get("typ")).isEqualTo("JWT"), + () -> assertThat(claimsJson.get("iss")).isEqualTo(iss) + ); + + Long iat = Long.valueOf(claimsJson.get("iat").toString()); + Long exp = Long.valueOf(claimsJson.get("exp").toString()); + assertThat(iat).isLessThan(exp); + } + + @DisplayName("토큰 디코딩 실패") + @Test + void decoding_token_failBy_url() { + // given + String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJpc3MiOiJtb2Ftb2Ftb2FiYW0iLCJpYXQiOjE3MDEyMzQyNjksImV4c" + + "CI6MTcwMTIzNDU2OSwiaWQiOjIsIm5pY2tuYW1lIjoiXHVEODNEXHVEQzNC6rOw64-M7J20Iiwicm9sZSI6IlVTRVIifQ" + + ".yVcvshWQ6fsQ0OQ-A5kolDo-8QsLVFCD6dIENKWZH-A"; + String[] parts = token.split("\\."); + + // when + then + assertThatThrownBy(() -> Base64.getDecoder().decode(parts[1])).isInstanceOf(IllegalArgumentException.class); + } + + @DisplayName("토큰 디코딩 성공") + @ParameterizedTest + @ValueSource(strings = { + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJpc3MiOiJtb2Ftb2Ftb2FiYW0iLCJpYXQiOjE3MDEyMzQyNjksImV4cCI6MTcwMjQ0Mzg2OX0" + + ".IrcH_LvBKK1HezgY3PVY-0HQlhP6neEuydH6Mhz4Jgo", + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" + + ".eyJpc3MiOiJtb2Ftb2Ftb2FiYW0iLCJpYXQiOjE3MDEyMzQyNjksImV4cCI6MTcwMTIzNDU2OSwiaWQiOjIsIm" + + "5pY2tuYW1lIjoiXHVEODNEXHVEQzNC6rOw64-M7J20Iiwicm9sZSI6IlVTRVIifQ" + + ".yVcvshWQ6fsQ0OQ-A5kolDo-8QsLVFCD6dIENKWZH-A" + }) + void decoding_token_success(String token) { + // given + String[] parts = token.split("\\."); + + // When + Then + assertThatNoException().isThrownBy(() -> Decoders.BASE64URL.decode(parts[1])); + } + + @DisplayName("refresh 토큰 생성 성공") + @Test + void create_refresh_token_success() throws JSONException { + // given + long refreshExpire = 15000L; + + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey, adminKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String refreshToken = jwtProviderService.provideRefreshToken(Role.USER); + + String[] parts = refreshToken.split("\\."); + String headers = new String(Base64.getDecoder().decode(parts[0])); + String claims = new String(Base64.getDecoder().decode(parts[1])); + + JSONObject headersJson = new JSONObject(headers); + JSONObject claimsJson = new JSONObject(claims); + + // then + assertAll( + () -> assertThat(headersJson.get("alg")).isEqualTo("HS256"), + () -> assertThat(headersJson.get("typ")).isEqualTo("JWT"), + () -> assertThat(claimsJson.get("iss")).isEqualTo(iss) + ); + + Long iat = Long.valueOf(claimsJson.get("iat").toString()); + Long exp = Long.valueOf(claimsJson.get("exp").toString()); + assertThat(iat).isLessThan(exp); + } + + @DisplayName("access 토큰 만료시간에 따른 생성 실패") + @Test + void create_access_token_fail() { + // given + long accessExpire = -1L; + + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey, adminKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + // when + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + + // then + assertThatThrownBy(() -> Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(accessToken) + ).isInstanceOf(ExpiredJwtException.class); + } + + @DisplayName("refresh 토큰 만료시간에 따른 생성 실패") + @Test + void create_token_fail() { + // given + long refreshExpire = -1L; + + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey, adminKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + // when + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + + // then + assertThatThrownBy(() -> Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(accessToken) + ).isExactlyInstanceOf(ExpiredJwtException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestServiceTest.java new file mode 100644 index 00000000..1d3e1ae2 --- /dev/null +++ b/src/test/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestServiceTest.java @@ -0,0 +1,266 @@ +package com.moabam.api.application.auth; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +public class OAuth2AuthorizationServerRequestServiceTest { + + @InjectMocks + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + + @Mock + RestTemplate restTemplate; + + @BeforeEach + void initField() { + ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); + } + + @DisplayName("로그인 페이지 접근 요청") + @Nested + class LoginPage { + + String uri = "https://authorization/url?" + + "response_type=code&" + + "client_id=testtestetsttest&" + + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; + + @DisplayName("로그인 페이지 접근 요청 성공") + @Test + void authorization_code_uri_generate_success() { + // Given + MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); + + // When + oAuth2AuthorizationServerRequestService.loginRequest(mockHttpServletResponse, uri); + + // Then + assertThat(mockHttpServletResponse.getContentType()) + .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); + } + + @DisplayName("redirect 실패 테스트") + @Test + void redirect_fail() { + // Given + HttpServletResponse mockHttpServletResponse = mock(HttpServletResponse.class); + + try { + doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); + + assertThatThrownBy(() -> { + // When + Then + oAuth2AuthorizationServerRequestService.loginRequest(mockHttpServletResponse, uri); + }).isExactlyInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.REQUEST_FAILED.getMessage()); + } catch (Exception ignored) { + + } + } + } + + @DisplayName("Authorization Server 토큰 발급 요청") + @Nested + class TokenRequest { + + @DisplayName("토큰 발급 요청 성공") + @Test + void token_issue_request_success() { + // Given + String tokenUri = "test"; + MultiValueMap uriParams = new LinkedMultiValueMap<>(); + + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); + + // When + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)); + oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams); + + // Then + verify(restTemplate, times(1)) + .exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + } + + @DisplayName("토큰 발급 요청 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401, 403, 429, 500, 502, 503}) + void token_issue_request_fail(int code) { + // Given + String tokenUri = "test"; + MultiValueMap uriParams = new LinkedMultiValueMap<>(); + + // When + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)); + + // Then + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)) + .isInstanceOf(HttpClientErrorException.class); + } + } + + @DisplayName("토큰 정보 조회 발급 요청") + @Nested + class TokenInfoRequest { + + @DisplayName("토큰 정보 조회 요청 성공") + @Test + void token_info_request_success() { + // Given + String tokenInfoUri = "http://tokenInfo/uri"; + String tokenValue = "Bearer access-token"; + + HttpHeaders headers = new HttpHeaders(); + headers.add("Authorization", tokenValue); + HttpEntity httpEntity = new HttpEntity<>(headers); + + // When + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(tokenInfoUri), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(AuthorizationTokenInfoResponse.class)); + oAuth2AuthorizationServerRequestService.tokenInfoRequest(tokenInfoUri, tokenValue); + + // Then + verify(restTemplate, times(1)) + .exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); + } + + @DisplayName("토큰 발급 요청 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401}) + void token_issue_request_fail(int code) { + // Given + String tokenInfoUri = "http://tokenInfo/uri"; + String tokenValue = "Bearer access-token"; + + // When + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(tokenInfoUri), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(AuthorizationTokenInfoResponse.class)); + + // Then + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.tokenInfoRequest(tokenInfoUri, tokenValue)) + .isInstanceOf(HttpClientErrorException.class); + } + } + + @DisplayName("회원 연결 끊기 요청") + @Nested + class Delete { + + @DisplayName("성공") + @Test + void token_info_request_success() { + // Given + String deleteUri = "https://deleteUrl/uri"; + String adminKey = "admin-token"; + String socialId = "1"; + + HttpHeaders headers = new HttpHeaders(); + headers.add(HttpHeaders.CONTENT_TYPE, + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8); + headers.add("Authorization", "KakaoAK " + adminKey); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", socialId); + + HttpEntity> httpEntity = new HttpEntity<>(params, headers); + + // When + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(deleteUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Void.class)); + oAuth2AuthorizationServerRequestService.unlinkMemberRequest(deleteUri, adminKey, params); + + // Then + verify(restTemplate, times(1)) + .exchange(deleteUri, HttpMethod.POST, httpEntity, Void.class); + } + + @DisplayName("실패") + @ParameterizedTest + @ValueSource(ints = {400, 401}) + void token_issue_request_fail(int code) { + // Given + String deleteUri = "https://deleteUrl/uri"; + String adminKey = "admin-token"; + String socialId = "1"; + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", socialId); + + // When + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(deleteUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(Void.class)); + + // Then + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.unlinkMemberRequest(deleteUri, adminKey, params)) + .isInstanceOf(HttpClientErrorException.class); + } + } +} diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java new file mode 100644 index 00000000..7c147a98 --- /dev/null +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -0,0 +1,183 @@ +package com.moabam.api.application.bug; + +import static com.moabam.api.domain.product.ProductType.*; +import static com.moabam.support.fixture.BugFixture.*; +import static com.moabam.support.fixture.CouponFixture.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.payment.PaymentMapper; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductResponse; +import com.moabam.api.dto.product.ProductsResponse; +import com.moabam.api.dto.product.PurchaseProductRequest; +import com.moabam.api.dto.product.PurchaseProductResponse; +import com.moabam.global.common.util.StreamUtils; +import com.moabam.global.error.exception.NotFoundException; + +@ExtendWith(MockitoExtension.class) +class BugServiceTest { + + @InjectMocks + BugService bugService; + + @Mock + MemberService memberService; + + @Mock + BugHistoryRepository bugHistoryRepository; + + @Mock + ProductRepository productRepository; + + @Mock + PaymentRepository paymentRepository; + + @Mock + CouponWalletSearchRepository couponWalletSearchRepository; + + @DisplayName("벌레를 조회한다.") + @Test + void get_bug_success() { + // given + Long memberId = 1L; + Member member = member(); + given(memberService.findMember(memberId)).willReturn(member); + + // when + BugResponse response = bugService.getBug(memberId); + + // then + Bug bug = member.getBug(); + assertThat(response.morningBug()).isEqualTo(bug.getMorningBug()); + assertThat(response.nightBug()).isEqualTo(bug.getNightBug()); + assertThat(response.goldenBug()).isEqualTo(bug.getGoldenBug()); + } + + @DisplayName("벌레 상품 목록을 조회한다.") + @Test + void get_bug_products_success() { + // given + Product product1 = bugProduct(); + Product product2 = bugProduct(); + given(productRepository.findAllByType(BUG)).willReturn(List.of(product1, product2)); + + // when + ProductsResponse response = bugService.getBugProducts(); + + // then + List productNames = StreamUtils.map(response.products(), ProductResponse::name); + assertThat(response.products()).hasSize(2); + assertThat(productNames).containsExactly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); + } + + @DisplayName("벌레 상품을 구매한다.") + @Nested + class PurchaseBugProduct { + + @DisplayName("쿠폰 적용에 성공한다.") + @Test + void apply_coupon_success() { + // given + Long memberId = 1L; + Long productId = 1L; + Long couponWalletId = 1L; + CouponWallet couponWallet = CouponWallet.create(memberId, discount1000Coupon()); + Payment payment = PaymentMapper.toPayment(memberId, bugProduct()); + PurchaseProductRequest request = new PurchaseProductRequest(couponWalletId); + given(productRepository.findById(productId)).willReturn(Optional.of(bugProduct())); + given(paymentRepository.save(any(Payment.class))).willReturn(payment); + given(couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId)).willReturn( + Optional.of(couponWallet)); + + // when + PurchaseProductResponse response = bugService.purchaseBugProduct(memberId, productId, request); + + // then + assertThat(response.price()).isEqualTo(BUG_PRODUCT_PRICE - 1000); + assertThat(response.orderName()).isEqualTo(BUG_PRODUCT_NAME); + } + + @DisplayName("해당 상품이 존재하지 않으면 예외가 발생한다.") + @Test + void product_not_found_exception() { + // given + Long memberId = 1L; + Long productId = 1L; + PurchaseProductRequest request = new PurchaseProductRequest(null); + given(productRepository.findById(productId)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> bugService.purchaseBugProduct(memberId, productId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 상품입니다."); + } + } + + @DisplayName("벌레를 사용한다.") + @Test + void use_success() { + // given + Member member = spy(member()); + given(member.getId()).willReturn(1L); + + // when + bugService.use(member, BugType.MORNING, 5); + + // then + assertThat(member.getBug().getMorningBug()).isEqualTo(MORNING_BUG - 5); + } + + @DisplayName("벌레 보상을 준다.") + @Test + void reward_success() { + // given + Member member = spy(member()); + given(member.getId()).willReturn(1L); + + // when + bugService.reward(member, BugType.NIGHT, 5); + + // then + assertThat(member.getBug().getNightBug()).isEqualTo(NIGHT_BUG + 5); + } + + @DisplayName("벌레를 충전한다.") + @Test + void charge_success() { + // given + Long memberId = 1L; + Member member = member(); + given(memberService.findMember(memberId)).willReturn(member); + + // when + bugService.charge(memberId, bugProduct()); + + // then + assertThat(member.getBug().getGoldenBug()).isEqualTo(GOLDEN_BUG + BUG_PRODUCT_QUANTITY); + } +} diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java new file mode 100644 index 00000000..d209cdfe --- /dev/null +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -0,0 +1,172 @@ +package com.moabam.api.application.coupon; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.Optional; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.notification.NotificationService; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponManageRepository; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.CouponFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class CouponManageServiceTest { + + @InjectMocks + CouponManageService couponManageService; + + @Mock + NotificationService notificationService; + + @Mock + CouponRepository couponRepository; + + @Mock + CouponManageRepository couponManageRepository; + + @Mock + CouponWalletRepository couponWalletRepository; + + @Mock + ClockHolder clockHolder; + + @DisplayName("10명의 사용자가 쿠폰 발행을 성공적으로 한다.") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") + @ParameterizedTest + void issue_all_success(Set values) { + // Given + Coupon coupon = CouponFixture.coupon(1000, 100); + + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponManageRepository.rangeQueue(any(String.class), any(long.class), any(long.class))) + .willReturn(values); + given(couponManageRepository.getCount(any(String.class))).willReturn(coupon.getMaxCount() - 1); + + // When + couponManageService.issue(); + + // Then + verify(couponWalletRepository, times(10)).save(any(CouponWallet.class)); + verify(notificationService, times(10)) + .sendCouponIssueResult(any(Long.class), any(String.class), any(String.class)); + } + + @DisplayName("발행 가능한 쿠폰이 없다.") + @Test + void issue_notStartAt() { + // Given + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); + + // When + couponManageService.issue(); + + // Then + verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + verify(couponManageRepository, times(0)) + .rangeQueue(any(String.class), any(long.class), any(long.class)); + verify(notificationService, times(0)) + .sendCouponIssueResult(any(Long.class), any(String.class), any(String.class)); + } + + @DisplayName("해당 쿠폰은 재고가 마감된 쿠폰이다.") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") + @ParameterizedTest + void issue_stockEnd() { + // Given + Coupon coupon = CouponFixture.coupon(1000, 100); + + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponManageRepository.getCount(any(String.class))).willReturn(coupon.getMaxCount()); + + // When + couponManageService.issue(); + + // Then + verify(couponManageRepository, times(0)).increase(any(String.class), any(int.class)); + verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + verify(couponManageRepository, times(0)).rangeQueue(any(String.class), any(int.class), any(int.class)); + verify(notificationService, times(0)) + .sendCouponIssueResult(any(Long.class), any(String.class), any(String.class)); + } + + @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") + @Test + void registerQueue_success() { + // Given + Coupon coupon = CouponFixture.coupon(); + + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponManageRepository.sizeQueue(any(String.class))).willReturn(coupon.getMaxCount() - 1); + given(couponRepository.findByNameAndStartAt(any(String.class), any(LocalDate.class))) + .willReturn(Optional.of(coupon)); + + // When + couponManageService.registerQueue(coupon.getName(), 1L); + + // Then + verify(couponManageRepository).addIfAbsentQueue(any(String.class), any(Long.class), any(double.class)); + } + + @DisplayName("금일 발급이 가능한 쿠폰이 없다. - BadRequestException") + @Test + void registerQueue_No_BadRequestException() { + // Given + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponRepository.findByNameAndStartAt(any(String.class), any(LocalDate.class))) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponManageService.registerQueue("couponName", 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); + } + + @DisplayName("쿠폰 대기열과 발행된 재고가 정상적으로 삭제된다.") + @Test + void deleteQueue_success() { + // Given + String couponName = "couponName"; + + // When + couponManageService.delete(couponName); + + // Then + verify(couponManageRepository).deleteQueue(couponName); + } + + @DisplayName("쿠폰 대기열이 정상적으로 삭제되지 않는다.") + @Test + void deleteQueue_NullPointerException() { + // Given + willThrow(NullPointerException.class) + .given(couponManageRepository) + .deleteQueue(any(String.class)); + + // When & Then + assertThatThrownBy(() -> couponManageService.delete("null")) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java new file mode 100644 index 00000000..b44d6f8c --- /dev/null +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -0,0 +1,367 @@ +package com.moabam.api.application.coupon; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.coupon.CouponResponse; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.api.dto.coupon.MyCouponResponse; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.CouponFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class CouponServiceTest { + + @InjectMocks + CouponService couponService; + + @Mock + BugService bugService; + + @Mock + CouponManageService couponManageService; + + @Mock + CouponRepository couponRepository; + + @Mock + CouponWalletRepository couponWalletRepository; + + @Mock + CouponSearchRepository couponSearchRepository; + + @Mock + CouponWalletSearchRepository couponWalletSearchRepository; + + @Mock + ClockHolder clockHolder; + + @DisplayName("관리자가 쿠폰을 성공적으로 발행한다. - Void") + @Test + void create_success() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When + couponService.create(request, 1L, Role.ADMIN); + + // Then + verify(couponRepository).save(any(Coupon.class)); + } + + @DisplayName("권한 없는 사용자가 쿠폰을 발행한다. - NotFoundException") + @Test + void create_Admin_NotFoundException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.USER)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") + @Test + void create_Type_NotFoundException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 2, 1); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + } + + @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") + @Test + void create_Name_ConflictException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(couponRepository.existsByName(any(String.class))).willReturn(true); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.CONFLICT_COUPON_NAME.getMessage()); + } + + @DisplayName("중복된 쿠폰 발행 가능 날짜를 발행한다. - ConflictException") + @Test + void create_StartAt_ConflictException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(true); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.CONFLICT_COUPON_START_AT.getMessage()); + } + + @DisplayName("현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") + @Test + void create_StartAt_BadRequestException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(clockHolder.date()).willReturn(LocalDate.of(2025, 1, 1)); + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(false); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_START_AT_PERIOD.getMessage()); + } + + @DisplayName("쿠폰 정보 오픈 날짜가 쿠폰 발급 시작 날짜와 같거나 이후인 쿠폰을 발행한다. - BadRequestException") + @Test + void create_OpenAt_BadRequestException() { + // Given + String couponType = CouponType.GOLDEN.getName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(false); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD.getMessage()); + } + + @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 성공적으로 삭제한다. - Void") + @Test + void delete_success() { + // Given + Coupon coupon = CouponFixture.coupon(10, 100); + + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); + + // When + couponService.delete(1L, Role.ADMIN); + + // Then + verify(couponRepository).delete(coupon); + verify(couponManageService).delete(any(String.class)); + } + + @DisplayName("권한 없는 사용자가 쿠폰을 삭제한다. - NotFoundException") + @Test + void delete_Admin_NotFoundException() { + // When & Then + assertThatThrownBy(() -> couponService.delete(1L, Role.USER)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @DisplayName("존재하지 않는 쿠폰 아이디를 삭제하려고 시도한다. - NotFoundException") + @Test + void delete_NotFoundException() { + // Given + given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponService.delete(1L, Role.ADMIN)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); + } + + @DisplayName("특정 쿠폰을 성공적으로 조회한다. - CouponResponse") + @Test + void getById_success() { + // Given + Coupon coupon = CouponFixture.coupon(10, 100); + + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); + + // When + CouponResponse actual = couponService.getById(1L); + + // Then + assertThat(actual.point()).isEqualTo(coupon.getPoint()); + assertThat(actual.maxCount()).isEqualTo(coupon.getMaxCount()); + } + + @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @Test + void getById_NotFoundException() { + // Given + given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponService.getById(1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); + } + + @DisplayName("모든 쿠폰을 성공적으로 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void getAllByStatus_success(List coupons) { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); + + given(clockHolder.date()).willReturn(LocalDate.now()); + given(couponSearchRepository.findAllByStatus(any(LocalDate.class), any(CouponStatusRequest.class))) + .willReturn(coupons); + + // When + List actual = couponService.getAllByStatus(request); + + // Then + assertThat(actual).hasSize(coupons.size()); + } + + @DisplayName("나의 모든 쿠폰을 성공적으로 조회한다.") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletByCouponId1_total5") + @ParameterizedTest + void getAllByWalletIdAndMemberId_all_success(List couponWallets) { + // Given + given(couponWalletSearchRepository.findAllByIdAndMemberId(isNull(), any(Long.class))) + .willReturn(couponWallets); + + // When + List actual = couponService.getAllByWalletIdAndMemberId(null, 1L); + + // Then + assertThat(actual).hasSize(couponWallets.size()); + } + + @DisplayName("나의 특정 쿠폰을 성공적으로 조회한다.") + @Test + void getAllByWalletIdAndMemberId_success() { + // Given + Coupon coupon = CouponFixture.coupon(); + List couponWallets = List.of(CouponWallet.create(1L, coupon)); + + given(couponWalletSearchRepository.findAllByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(couponWallets); + + // When + List actual = couponService.getAllByWalletIdAndMemberId(1L, 1L); + + // Then + assertThat(actual).hasSize(1); + } + + @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있는 특정 쿠폰을 성공적으로 사용한다. - Void") + @Test + void use_success() { + // Given + Coupon coupon = CouponFixture.coupon(CouponType.GOLDEN, 1000); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.of(couponWallet)); + + // When + couponService.use(1L, 1L); + + // Then + verify(couponWalletRepository).delete(any(CouponWallet.class)); + verify(bugService).applyCoupon(any(Long.class), any(BugType.class), any(int.class)); + } + + @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있는 할인 쿠폰을 사용한다. - BadRequestException") + @Test + void use_BadRequestException() { + // Given + Coupon coupon = CouponFixture.coupon(CouponType.DISCOUNT, 1000); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.of(couponWallet)); + + // When & Then + assertThatThrownBy(() -> couponService.use(1L, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_DISCOUNT_COUPON.getMessage()); + } + + @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있지 않은 쿠폰을 사용한다. - NotFoundException") + @Test + void use_NotFoundException() { + // Given + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponService.use(1L, 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_WALLET.getMessage()); + } + + @DisplayName("결제할 때, 할인 쿠폰을 사용한다. - Void") + @Test + void discount_success() { + // Given + Coupon coupon = CouponFixture.coupon(CouponType.DISCOUNT, 1000); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.of(couponWallet)); + + // When + couponService.discount(1L, 1L); + + // Then + verify(couponWalletRepository).delete(couponWallet); + } + + @DisplayName("결제할 때, 벌레 쿠폰을 사용한다. - BadRequestException") + @Test + void discount_BadRequestException() { + // Given + Coupon coupon = CouponFixture.coupon(CouponType.GOLDEN, 1000); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.of(couponWallet)); + + // When & Then + assertThatThrownBy(() -> couponService.discount(1L, 1L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_BUG_COUPON.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/application/image/ImageServiceTest.java b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java new file mode 100644 index 00000000..ea2e67ed --- /dev/null +++ b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java @@ -0,0 +1,62 @@ +package com.moabam.api.application.image; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.domain.image.NewImage; +import com.moabam.api.infrastructure.s3.S3Manager; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class ImageServiceTest { + + @InjectMocks + private ImageService imageService; + + @Mock + private S3Manager s3Manager; + + @DisplayName("이미지 리사이징 이후 업로드 성공") + @Test + void image_resize_upload_success() { + // given + List multipartFiles = new ArrayList<>(); + ImageType imageType = ImageType.CERTIFICATION; + MockMultipartFile image1 = RoomFixture.makeMultipartFile1(); + List images = List.of(image1); + + given(s3Manager.uploadImage(anyString(), any(NewImage.class))).willReturn(image1.getName()); + + // when + List result = imageService.uploadImages(images, imageType); + + // then + assertThat(image1.getName()).isEqualTo(result.get(0)); + } + + @DisplayName("이미지 삭제 성공") + @Test + void delete_image_success() { + // given + String imageUrl = "test"; + + // when + imageService.deleteImage(imageUrl); + + // then + verify(s3Manager).deleteImage(imageUrl); + } +} diff --git a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java new file mode 100644 index 00000000..280c70fe --- /dev/null +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -0,0 +1,189 @@ +package com.moabam.api.application.item; + +import static com.moabam.support.fixture.InventoryFixture.*; +import static com.moabam.support.fixture.ItemFixture.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.item.repository.ItemSearchRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.item.ItemResponse; +import com.moabam.api.dto.item.ItemsResponse; +import com.moabam.api.dto.item.PurchaseItemRequest; +import com.moabam.global.common.util.StreamUtils; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + + @InjectMocks + ItemService itemService; + + @Mock + MemberService memberService; + + @Mock + BugService bugService; + + @Mock + ItemRepository itemRepository; + + @Mock + ItemSearchRepository itemSearchRepository; + + @Mock + InventoryRepository inventoryRepository; + + @Mock + InventorySearchRepository inventorySearchRepository; + + @Mock + BugHistoryRepository bugHistoryRepository; + + @DisplayName("아이템 목록을 조회한다.") + @Test + void get_products_success() { + // given + Long memberId = 1L; + ItemType type = ItemType.MORNING; + Item item1 = morningSantaSkin().build(); + Item item2 = morningKillerSkin().build(); + Inventory inventory = inventory(memberId, item1); + given(inventorySearchRepository.findDefault(memberId, type)).willReturn(Optional.of(inventory)); + given(inventorySearchRepository.findItems(memberId, type)).willReturn(List.of(item1, item2)); + given(itemSearchRepository.findNotPurchasedItems(memberId, type)).willReturn(emptyList()); + + // when + ItemsResponse response = itemService.getItems(memberId, type); + + // then + List purchasedItemNames = StreamUtils.map(response.purchasedItems(), ItemResponse::name); + assertThat(response.purchasedItems()).hasSize(2); + assertThat(purchasedItemNames).containsExactly(MORNING_SANTA_SKIN_NAME, MORNING_KILLER_SKIN_NAME); + assertThat(response.notPurchasedItems()).isEmpty(); + } + + @DisplayName("아이템을 구매한다.") + @Nested + class PurchaseItem { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Long memberId = 1L; + Long itemId = 1L; + PurchaseItemRequest request = new PurchaseItemRequest(BugType.GOLDEN); + Member member = member(); + Item item = nightMageSkin(); + given(memberService.findMember(memberId)).willReturn(member); + given(itemRepository.findById(itemId)).willReturn(Optional.of(item)); + given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.empty()); + + // When + itemService.purchaseItem(memberId, itemId, request); + + // Then + verify(bugService).use(any(Member.class), any(BugType.class), anyInt()); + verify(inventoryRepository).save(any(Inventory.class)); + } + + @DisplayName("해당 아이템이 존재하지 않으면 예외가 발생한다.") + @Test + void item_not_found_exception() { + // given + Long memberId = 1L; + Long itemId = 1L; + PurchaseItemRequest request = new PurchaseItemRequest(BugType.GOLDEN); + given(itemRepository.findById(itemId)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> itemService.purchaseItem(memberId, itemId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 아이템입니다."); + } + + @DisplayName("이미 구매한 아이템이면 예외가 발생한다.") + @Test + void inventory_conflict_exception() { + // given + Long memberId = 1L; + Long itemId = 1L; + PurchaseItemRequest request = new PurchaseItemRequest(BugType.GOLDEN); + Item item = nightMageSkin(); + Inventory inventory = inventory(memberId, item); + given(itemRepository.findById(itemId)).willReturn(Optional.of(item)); + given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.of(inventory)); + + // when, then + assertThatThrownBy(() -> itemService.purchaseItem(memberId, itemId, request)) + .isInstanceOf(ConflictException.class) + .hasMessage("이미 구매한 아이템입니다."); + } + } + + @DisplayName("아이템을 적용한다.") + @Nested + class SelectItem { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Long memberId = 1L; + Long itemId = 1L; + Inventory inventory = inventory(memberId, nightMageSkin()); + Inventory defaultInventory = inventory(memberId, nightMageSkin()); + ItemType itemType = inventory.getItemType(); + given(memberService.findMember(memberId)).willReturn(member()); + given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.of(inventory)); + given(inventorySearchRepository.findDefault(memberId, itemType)).willReturn(Optional.of(defaultInventory)); + + // when + itemService.selectItem(memberId, itemId); + + // then + assertFalse(defaultInventory.isDefault()); + assertTrue(inventory.isDefault()); + } + + @DisplayName("인벤토리 아이템이 아니면 예외가 발생한다.") + @Test + void exception() { + // given + Long memberId = 1L; + Long itemId = 1L; + given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> itemService.selectItem(memberId, itemId)) + .isInstanceOf(NotFoundException.class) + .hasMessage("구매하지 않은 아이템은 적용할 수 없습니다."); + } + } +} diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java new file mode 100644 index 00000000..5bb68977 --- /dev/null +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -0,0 +1,249 @@ +package com.moabam.api.application.member; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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; + +import com.moabam.api.application.ranking.RankingService; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.member.repository.MemberSearchRepository; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.member.MemberInfo; +import com.moabam.api.dto.member.MemberInfoResponse; +import com.moabam.api.dto.member.ModifyMemberRequest; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.InventoryFixture; +import com.moabam.support.fixture.ItemFixture; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.MemberInfoSearchFixture; +import com.moabam.support.fixture.ModifyImageFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class MemberServiceTest { + + @InjectMocks + MemberService memberService; + + @Mock + MemberRepository memberRepository; + + @Mock + MemberSearchRepository memberSearchRepository; + + @Mock + ParticipantRepository participantRepository; + + @Mock + ParticipantSearchRepository participantSearchRepository; + + @Mock + InventorySearchRepository inventorySearchRepository; + + @Mock + InventoryRepository inventoryRepository; + + @Mock + RankingService rankingService; + + @Mock + FcmService fcmService; + + @Mock + ItemRepository itemRepository; + + @Mock + ClockHolder clockHolder; + + @DisplayName("회원 존재하고 로그인 성공") + @Test + void member_exist_and_login_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + Member member = MemberFixture.member(); + willReturn(Optional.of(member)) + .given(memberRepository).findBySocialId(String.valueOf(authorizationTokenInfoResponse.id())); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(result.publicClaim().id()).isEqualTo(member.getId()); + assertThat(result.isSignUp()).isFalse(); + } + + @DisplayName("회원가입 성공") + @Test + void signUp_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + willReturn(Optional.empty()) + .given(memberRepository).findBySocialId(String.valueOf(authorizationTokenInfoResponse.id())); + + Member member = spy(MemberFixture.member()); + given(member.getId()).willReturn(1L); + willReturn(member) + .given(memberRepository).save(any(Member.class)); + willReturn(List.of(ItemFixture.morningSantaSkin().build(), ItemFixture.nightMageSkin())) + .given(itemRepository).findAllById(any()); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(authorizationTokenInfoResponse.id()).isEqualTo(result.publicClaim().id()); + assertThat(result.isSignUp()).isTrue(); + } + + @DisplayName("회원 삭제 성공") + @Test + void undo_delete_member(@WithMember AuthMember authMember) { + // given + Member member = MemberFixture.member(); + given(clockHolder.times()).willReturn(LocalDateTime.now()); + + // When + memberService.delete(member); + + // then + assertThat(member).isNotNull(); + assertThat(member.getSocialId()).contains("delete"); + } + + @DisplayName("내 회원 정보가 없어서 예외 발생") + @Test + void search_my_info_failBy_member_null(@WithMember AuthMember authMember) { + // given + given(memberSearchRepository.findMemberAndBadges(authMember.id(), true)) + .willReturn(List.of()); + + // When + Then + assertThatThrownBy(() -> memberService.searchInfo(authMember, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @DisplayName("친구 회원 정보가 없어서 예외 발생") + @Test + void search_friend_info_failBy_member_null(@WithMember AuthMember authMember) { + // given + given(memberSearchRepository.findMemberAndBadges(123L, false)) + .willReturn(List.of()); + + // When + Then + assertThatThrownBy(() -> memberService.searchInfo(authMember, 123L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @DisplayName("내 기본 스킨 2개가 없을 때 예외 발생") + @Test + void search_my_info_success(@WithMember AuthMember authMember) { + // Given + long total = 36; + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + + given(memberSearchRepository.findMemberAndBadges(authMember.id(), true)) + .willReturn(MemberInfoSearchFixture.friendMemberInfo(total)); + + // When + Then + MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); + + assertAll( + () -> assertThat(memberInfoResponse.exp()).isEqualTo(total % 10), + () -> assertThat(memberInfoResponse.level()).isEqualTo(total / 10) + ); + } + + @DisplayName("기본 스킨을 가져온다.") + @Nested + class GetDefaultSkin { + + @DisplayName("성공") + @Test + void success(@WithMember AuthMember authMember) { + // given + long searchId = 1L; + Item morning = ItemFixture.morningSantaSkin().build(); + Item night = ItemFixture.nightMageSkin(); + Inventory morningSkin = InventoryFixture.inventory(searchId, morning); + Inventory nightSkin = InventoryFixture.inventory(searchId, night); + List memberInfos = MemberInfoSearchFixture + .myInfo(morningSkin.getItem().getAwakeImage(), nightSkin.getItem().getAwakeImage()); + + given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) + .willReturn(memberInfos); + + // when + MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); + + // then + assertThat(memberInfoResponse.birds()).containsEntry("MORNING", morningSkin.getItem().getAwakeImage()); + assertThat(memberInfoResponse.birds()).containsEntry("NIGHT", nightSkin.getItem().getAwakeImage()); + } + } + + @DisplayName("사용자 정보 수정 성공") + @Test + void modify_success_test(@WithMember AuthMember authMember) { + // given + Member member = MemberFixture.member(); + ModifyMemberRequest modifyMemberRequest = ModifyImageFixture.modifyMemberRequest(); + given(memberSearchRepository.findMember(authMember.id())).willReturn(Optional.ofNullable(member)); + given(participantSearchRepository.findAllRoomMangerByMemberId(any())) + .willReturn(List.of()); + + // when + memberService.modifyInfo(authMember, modifyMemberRequest, "/main"); + + // Then + assertAll( + () -> assertThat(member.getNickname()).isEqualTo(modifyMemberRequest.nickname()), + () -> assertThat(member.getIntro()).isEqualTo(modifyMemberRequest.intro()), + () -> assertThat(member.getProfileImage()).isEqualTo("/main") + ); + } + + @DisplayName("모든 랭킹 업데이트") + @Test + void update_all_ranking() { + // given + Member member1 = MemberFixture.member("1"); + Member member2 = MemberFixture.member("2"); + given(memberSearchRepository.findAllMembers()) + .willReturn(List.of(member1, member2)); + + // when + Then + assertThatNoException().isThrownBy(() -> memberService.updateAllRanking()); + } +} diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java new file mode 100644 index 00000000..a08d2b06 --- /dev/null +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -0,0 +1,234 @@ +package com.moabam.api.application.notification; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.notification.repository.NotificationRepository; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class NotificationServiceTest { + + @InjectMocks + NotificationService notificationService; + + @Mock + MemberService memberService; + + @Mock + RoomService roomService; + + @Mock + FcmService fcmService; + + @Mock + NotificationRepository notificationRepository; + + @Mock + ParticipantSearchRepository participantSearchRepository; + + @Mock + ClockHolder clockHolder; + + String successIssueResult = "%s 쿠폰 발행을 성공했습니다. 축하드립니다!"; + + @DisplayName("상대에게 콕 알림을 성공적으로 보낸다. - Void") + @Test + void sendKnock_success() { + // Given + Room room = RoomFixture.room(); + Member member = MemberFixture.member(); + + given(roomService.findRoom(any(Long.class))).willReturn(room); + given(memberService.findMember(any(Long.class))).willReturn(member); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); + + // When + notificationService.sendKnock(1L, 1L, 2L); + + // Then + verify(fcmService).sendAsync(any(String.class), any(String.class), any(String.class)); + verify(notificationRepository).saveKnock(any(Long.class), any(Long.class), any(Long.class)); + } + + @DisplayName("콕 찌를 때, 방이 존재하지 않는다. - NotFoundException") + @Test + void sendKnock_Room_NotFoundException() { + // Given + given(roomService.findRoom(any(Long.class))).willThrow(NotFoundException.class); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) + .isInstanceOf(NotFoundException.class); + } + + @DisplayName("콕 찌를 상대가 존재하지 않는다. - NotFoundException") + @Test + void sendKnock_Member_NotFoundException() { + // Given + Room room = RoomFixture.room(); + + given(roomService.findRoom(any(Long.class))).willReturn(room); + given(memberService.findMember(any(Long.class))).willThrow(NotFoundException.class); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) + .isInstanceOf(NotFoundException.class); + } + + @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") + @Test + void sendKnock_FcmToken_NotFoundException() { + // Given + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); + } + + @DisplayName("콕 찌를 상대가 이미 찌른 상대이다. - ConflictException") + @Test + void sendKnock_ConflictException() { + // Given + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(true); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); + } + + @DisplayName("특정 사용자에게 쿠폰 이슈 결과를 성공적으로 전송한다. - Void") + @Test + void sendCouponIssueResult_success() { + // Given + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); + + // When + notificationService.sendCouponIssueResult(1L, "couponName", successIssueResult); + + // Then + verify(fcmService).sendAsync(any(String.class), any(String.class), any(String.class)); + } + + @DisplayName("로그아웃된 사용자에게 쿠폰 이슈 결과를 성공적으로 전송한다. - Void") + @Test + void sendCouponIssueResult_fcmToken_null() { + // Given + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); + + // When + notificationService.sendCouponIssueResult(1L, "couponName", successIssueResult); + + // Then + verify(fcmService).sendAsync(isNull(), any(String.class), any(String.class)); + } + + @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낸다. - Void") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void sendCertificationTime_success(List participants) { + // Given + given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); + given(clockHolder.times()).willReturn(LocalDateTime.now()); + + // When + notificationService.sendCertificationTime(); + + // Then + verify(fcmService, times(3)) + .sendAsync(any(String.class), any(String.class), any(String.class)); + } + + @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없다. - Void") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void sendCertificationTime_NoFirebaseMessaging(List participants) { + // Given + given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); + given(clockHolder.times()).willReturn(LocalDateTime.now()); + + // When + notificationService.sendCertificationTime(); + + // Then + verify(fcmService, times(0)) + .sendAsync(any(String.class), any(String.class), any(String.class)); + } + + @WithMember + @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸다. - List") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void getMyKnockStatusInRoom_knocked(List participants) { + // Given + AuthMember member = AuthorizationThreadLocal.getAuthMember(); + + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(true); + + // When + List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); + + // Then + assertThat(actual).hasSize(2); + } + + @WithMember + @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸 적이 없다. - List") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void getMyKnockStatusInRoom_notKnocked(List participants) { + // Given + AuthMember member = AuthorizationThreadLocal.getAuthMember(); + + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); + + // When + List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); + + // Then + assertThat(actual).isEmpty(); + } +} diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java new file mode 100644 index 00000000..4158682c --- /dev/null +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -0,0 +1,118 @@ +package com.moabam.api.application.payment; + +import static com.moabam.support.fixture.CouponFixture.*; +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +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; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.coupon.CouponService; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.PaymentStatus; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.payment.repository.PaymentSearchRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.exception.TossPaymentException; + +@ExtendWith(MockitoExtension.class) +class PaymentServiceTest { + + @InjectMocks + PaymentService paymentService; + + @Mock + BugService bugService; + + @Mock + CouponService couponService; + + @Mock + TossPaymentService tossPaymentService; + + @Mock + PaymentRepository paymentRepository; + + @Mock + PaymentSearchRepository paymentSearchRepository; + + @DisplayName("결제 요청 시 해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void request_not_found_exception() { + // given + Long memberId = 1L; + Long paymentId = 1L; + PaymentRequest request = new PaymentRequest(ORDER_ID); + given(paymentRepository.findById(paymentId)).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> paymentService.request(memberId, paymentId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); + } + + @DisplayName("결제 승인을 요청한다.") + @Nested + class RequestConfirm { + + @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void validate_info_not_found_exception() { + // given + Long memberId = 1L; + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> paymentService.requestConfirm(memberId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); + } + + @DisplayName("토스 결제 승인 요청이 실패하면 결제 실패 처리한다.") + @Test + void toss_fail() { + // given + Long memberId = 1L; + Payment payment = payment(bugProduct()); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(request)).willThrow(TossPaymentException.class); + + // when, then + assertThatThrownBy(() -> paymentService.requestConfirm(memberId, request)) + .isInstanceOf(TossPaymentException.class); + assertThat(payment.getPaymentKey()).isEqualTo(PAYMENT_KEY); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); + } + } + + @DisplayName("결제 승인에 성공한다.") + @Test + void confirm_success() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + + // when + paymentService.confirm(memberId, payment, PAYMENT_KEY); + + // then + verify(couponService, times(1)).discount(couponWalletId, memberId); + verify(bugService, times(1)).charge(memberId, payment.getProduct()); + } +} diff --git a/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java new file mode 100644 index 00000000..1dbb1843 --- /dev/null +++ b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java @@ -0,0 +1,234 @@ +package com.moabam.api.application.ranking; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; + +import com.moabam.api.application.member.MemberMapper; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.TopRankingInfo; +import com.moabam.api.dto.ranking.TopRankingResponse; +import com.moabam.api.dto.ranking.UpdateRanking; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; +import com.moabam.global.config.EmbeddedRedisConfig; +import com.moabam.support.fixture.BugFixture; +import com.moabam.support.fixture.MemberFixture; + +@SpringBootTest(classes = {EmbeddedRedisConfig.class, RankingService.class, ZSetRedisRepository.class}) +public class RankingServiceTest { + + @Autowired + ZSetRedisRepository zSetRedisRepository; + + @Autowired + RedisTemplate redisTemplate; + + @Autowired + RankingService rankingService; + + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + + @DisplayName("redis에 추가") + @Nested + class Add { + + @DisplayName("성공") + @Test + void add_success() { + // given + Long totalCertifyCount = 0L; + RankingInfo rankingInfo = RankingInfo.builder() + .image("https://image.moabam.com/test") + .memberId(1L) + .nickname("nickname") + .build(); + + // when + rankingService.addRanking(rankingInfo, totalCertifyCount); + + // then + Double resultDouble = redisTemplate.opsForZSet().score("Ranking", rankingInfo); + + assertAll(() -> assertThat(resultDouble).isNotNull(), + () -> assertThat(resultDouble).isEqualTo(Double.valueOf(totalCertifyCount))); + } + } + + @DisplayName("스코어 업데이트") + @Nested + class Update { + + @DisplayName("성공") + @Test + void update_success() { + // given + Member member = MemberFixture.member("1"); + member.increaseTotalCertifyCount(); + member.increaseTotalCertifyCount(); + + Member member1 = MemberFixture.member("2"); + member1.increaseTotalCertifyCount(); + member1.increaseTotalCertifyCount(); + + List members = List.of(member, member1); + List updateRankings = members.stream().map(MemberMapper::toUpdateRanking).toList(); + + // when + rankingService.updateScores(updateRankings); + + Double resultDouble = redisTemplate.opsForZSet().score("Ranking", updateRankings.get(0).rankingInfo()); + + // then + assertAll(() -> assertThat(resultDouble).isNotNull(), + () -> assertThat(resultDouble).isEqualTo(Double.valueOf(member.getTotalCertifyCount()))); + } + } + + @DisplayName("사용자 정보 변경") + @Nested + class Change { + + @DisplayName("성공") + @Test + void update_success() { + // given + Member member = Member.builder().socialId("1").bug(BugFixture.bug()).build(); + member.increaseTotalCertifyCount(); + member.increaseTotalCertifyCount(); + + long expect = member.getTotalCertifyCount(); + RankingInfo before = MemberMapper.toRankingInfo(member); + rankingService.addRanking(before, member.getTotalCertifyCount()); + + // when + member.changeIntro("밥세공기"); + RankingInfo changeInfo = MemberMapper.toRankingInfo(member); + rankingService.changeInfos(before, changeInfo); + + Double resultDouble = redisTemplate.opsForZSet().score("Ranking", changeInfo); + + // then + assertAll(() -> assertThat(resultDouble).isNotNull(), + () -> assertThat(resultDouble).isEqualTo(Double.valueOf(expect))); + } + } + + @DisplayName("랭킹 삭제") + @Nested + class Delete { + + @DisplayName("성공") + @Test + void update_success() { + // given + Long totalCertify = 5L; + Member member = Member.builder().socialId("1").bug(BugFixture.bug()).build(); + member.increaseTotalCertifyCount(); + member.increaseTotalCertifyCount(); + RankingInfo rankingInfo = MemberMapper.toRankingInfo(member); + + rankingService.addRanking(rankingInfo, totalCertify); + + // when + rankingService.removeRanking(rankingInfo); + + Double resultDouble = redisTemplate.opsForZSet().score("Ranking", rankingInfo); + + // then + assertThat(resultDouble).isNull(); + } + } + + @DisplayName("조회") + @Nested + class Select { + + @DisplayName("성공") + @Test + void test() { + // given + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(1L, "Hello1", "123"), 1); + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(2L, "Hello2", "123"), 2); + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(3L, "Hello3", "123"), 3); + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(4L, "Hello4", "123"), 4); + + // when + setSerialize(Object.class); + Set> rankings = redisTemplate.opsForZSet() + .reverseRangeWithScores("Ranking", 0, 2); + setSerialize(String.class); + + // then + assertThat(rankings).hasSize(3); + } + + @DisplayName("일부만 조회 성공") + @Test + void search_part() { + // given + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(1L, "Hello1", "123"), 1); + redisTemplate.opsForZSet().add("Ranking", new RankingInfo(2L, "Hello2", "123"), 2); + + // when + setSerialize(Object.class); + Set> rankings = redisTemplate.opsForZSet() + .reverseRangeWithScores("Ranking", 0, 10); + setSerialize(String.class); + + // then + assertThat(rankings).hasSize(2); + } + + private void setSerialize(Class classes) { + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(classes)); + } + + @DisplayName("랭킹 조회 성공") + @Test + void getTopRankings() { + // given + for (int i = 0; i < 20; i++) { + RankingInfo rankingInfo = new RankingInfo((long)(i + 1), "Hello" + (i + 1), "123"); + redisTemplate.opsForZSet().add("Ranking", rankingInfo, i + 1); + } + RankingInfo rankingInfo = new RankingInfo(21L, "Hello22", "123"); + redisTemplate.opsForZSet().add("Ranking", rankingInfo, 20); + RankingInfo rankingInfo2 = new RankingInfo(22L, "Hello23", "123"); + redisTemplate.opsForZSet().add("Ranking", rankingInfo2, 19); + + UpdateRanking myRanking = UpdateRanking.builder() + .score(1L) + .rankingInfo(RankingInfo.builder().nickname("Hello1").memberId(1L).image("123").build()) + .build(); + // When + TopRankingResponse topRankingResponse = rankingService.getMemberRanking(myRanking); + + // Then + List topRankings = topRankingResponse.topRankings(); + TopRankingInfo myRank = topRankingResponse.myRanking(); + assertAll(() -> assertThat(topRankings).hasSize(10), () -> assertThat(myRank.score()).isEqualTo(1), + () -> assertThat(topRankings.get(0).rank()).isEqualTo(1), + () -> assertThat(topRankings.get(1).rank()).isEqualTo(1), + () -> assertThat(topRankings.get(2).rank()).isEqualTo(2), + () -> assertThat(topRankings.get(3).rank()).isEqualTo(2), + () -> assertThat(topRankings.get(4).rank()).isEqualTo(3)); + + } + } +} diff --git a/src/test/java/com/moabam/api/application/report/ReportServiceTest.java b/src/test/java/com/moabam/api/application/report/ReportServiceTest.java new file mode 100644 index 00000000..4adc8c01 --- /dev/null +++ b/src/test/java/com/moabam/api/application/report/ReportServiceTest.java @@ -0,0 +1,93 @@ +package com.moabam.api.application.report; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.CertificationService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.report.repository.ReportRepository; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.dto.report.ReportRequest; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.ReportFixture; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class ReportServiceTest { + + @InjectMocks + ReportService reportService; + + @Mock + CertificationService certificationService; + + @Mock + RoomService roomService; + + @Mock + MemberService memberService; + + @Mock + ReportRepository reportRepository; + + @DisplayName("신고 대상이 없어서 실패") + @Test + void no_report_subject_fail(@WithMember AuthMember authMember) { + // given + ReportRequest reportRequest = new ReportRequest(null, null, null, "st"); + + // When + Then + assertThatThrownBy(() -> reportService.report(authMember, reportRequest)) + .isInstanceOf(BadRequestException.class) + .hasMessage(REPORT_REQUEST_ERROR.getMessage()); + } + + @DisplayName("신고 성공") + @ParameterizedTest + @CsvSource({"true, false", "false, true"}) + void report_success(boolean roomFilter, boolean certificationFilter, @WithMember AuthMember authMember) { + // given + Room room = RoomFixture.room(); + Routine routine = RoomFixture.routine(room, "ets"); + Certification certification = RoomFixture.certification(routine); + Member member = spy(MemberFixture.member()); + + Long roomId = null; + Long certificationId = null; + + if (roomFilter) { + given(roomService.findRoom(any())).willReturn(RoomFixture.room()); + roomId = 1L; + } + if (certificationFilter) { + given(certificationService.findCertification(any())).willReturn(certification); + certificationId = 1L; + } + + ReportRequest reportRequest = ReportFixture.reportRequest(2L, roomId, certificationId); + given(member.getId()).willReturn(2L); + given(memberService.findMember(reportRequest.reportedId())).willReturn(member); + + // When + Then + assertThatNoException() + .isThrownBy(() -> reportService.report(authMember, reportRequest)); + } +} diff --git a/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java new file mode 100644 index 00000000..3edc5543 --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java @@ -0,0 +1,128 @@ +package com.moabam.api.application.room; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.moabam.api.application.room.mapper.CertificationsMapper; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.room.repository.DailyRoomCertificationRepository; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.dto.room.CertifiedMemberInfo; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@SpringBootTest +class CertificationServiceConcurrencyTest { + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CertificationService certificationService; + + @Autowired + private DailyMemberCertificationRepository dailyMemberCertificationRepository; + + @Autowired + private DailyRoomCertificationRepository dailyRoomCertificationRepository; + + @DisplayName("방의 모든 참여자의 요청으로 방에 대한 인증") + @Test + void certify_room_success() throws InterruptedException { + // given + Room room = RoomFixture.room("테스트 하는 방이요", RoomType.MORNING, 9); + for (int i = 0; i < 4; i++) { + room.increaseCurrentUserCount(); + } + Room savedRoom = roomRepository.save(room); + + Member member1 = MemberFixture.member("0000"); + Member member2 = MemberFixture.member("1234"); + Member member3 = MemberFixture.member("5678"); + Member member4 = MemberFixture.member("3333"); + Member member5 = MemberFixture.member("5555"); + + List members = memberRepository.saveAll(List.of(member1, member2, member3, member4, member5)); + + Participant participant1 = RoomFixture.participant(savedRoom, member1.getId()); + Participant participant2 = RoomFixture.participant(savedRoom, member2.getId()); + Participant participant3 = RoomFixture.participant(savedRoom, member3.getId()); + Participant participant4 = RoomFixture.participant(savedRoom, member4.getId()); + Participant participant5 = RoomFixture.participant(savedRoom, member5.getId()); + + participantRepository.saveAll(List.of(participant1, participant2, participant3, participant4, participant5)); + + DailyMemberCertification dailyMemberCertification1 = RoomFixture.dailyMemberCertification(member1.getId(), + savedRoom.getId(), participant1); + DailyMemberCertification dailyMemberCertification2 = RoomFixture.dailyMemberCertification(member2.getId(), + savedRoom.getId(), participant2); + DailyMemberCertification dailyMemberCertification3 = RoomFixture.dailyMemberCertification(member3.getId(), + savedRoom.getId(), participant3); + DailyMemberCertification dailyMemberCertification4 = RoomFixture.dailyMemberCertification(member4.getId(), + savedRoom.getId(), participant4); + DailyMemberCertification dailyMemberCertification5 = RoomFixture.dailyMemberCertification(member5.getId(), + savedRoom.getId(), participant5); + + dailyMemberCertificationRepository.saveAll( + List.of(dailyMemberCertification1, dailyMemberCertification2, dailyMemberCertification3, + dailyMemberCertification4, dailyMemberCertification5)); + + int threadCount = 5; + ExecutorService executorService = Executors.newFixedThreadPool(10); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + // when + for (int i = 0; i < threadCount; i++) { + final int currentIndex = i; + + executorService.submit(() -> { + try { + CertifiedMemberInfo certifiedMemberInfo = CertificationsMapper.toCertifiedMemberInfo( + LocalDate.now(), BugType.MORNING, savedRoom, members.get(currentIndex)); + + certificationService.certifyRoom(certifiedMemberInfo); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + + Member savedMember1 = memberRepository.findById(member1.getId()).orElseThrow(); + List dailyRoomCertification = dailyRoomCertificationRepository.findAll(); + assertThat(savedMember1.getBug().getMorningBug()).isEqualTo(11); + assertThat(dailyRoomCertification).hasSize(1); + + participantRepository.deleteAll(); + memberRepository.deleteAllById( + List.of(member1.getId(), member2.getId(), member3.getId(), member4.getId(), member5.getId())); + dailyRoomCertificationRepository.deleteAll(); + dailyMemberCertificationRepository.deleteAll(); + } +} diff --git a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java new file mode 100644 index 00000000..e02eac44 --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -0,0 +1,199 @@ +package com.moabam.api.application.room; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.member.BadgeService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.mapper.CertificationsMapper; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationRepository; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.room.repository.DailyRoomCertificationRepository; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CertifiedMemberInfo; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class CertificationServiceTest { + + @InjectMocks + private CertificationService certificationService; + + @Mock + private MemberService memberService; + + @Mock + private BugService bugService; + + @Mock + private RoomRepository roomRepository; + + @Mock + private RoutineRepository routineRepository; + + @Mock + private ParticipantRepository participantRepository; + + @Mock + private CertificationRepository certificationRepository; + + @Mock + private CertificationsSearchRepository certificationsSearchRepository; + + @Mock + private ParticipantSearchRepository participantSearchRepository; + + @Mock + private DailyRoomCertificationRepository dailyRoomCertificationRepository; + + @Mock + private DailyMemberCertificationRepository dailyMemberCertificationRepository; + + @Mock + private ImageService imageService; + + @Mock + private BadgeService badgeService; + + @Mock + private ClockHolder clockHolder; + + @Spy + private Room room; + + @Spy + private Participant participant; + + private Member member1; + private Member member2; + private Member member3; + private LocalDate today; + private Long memberId; + private Long roomId; + + @BeforeEach + void init() { + room = spy(RoomFixture.room()); + participant = spy(RoomFixture.participant(room, 1L)); + member1 = MemberFixture.member("1"); + member2 = MemberFixture.member("2"); + member3 = MemberFixture.member("3"); + + lenient().when(room.getId()).thenReturn(1L); + lenient().when(participant.getRoom()).thenReturn(room); + + today = LocalDate.now(); + memberId = 1L; + roomId = room.getId(); + room.levelUp(); + room.levelUp(); + } + + @DisplayName("방 인증 전 개인 인증 후 정보 불러오기 성공") + @Test + void get_certified_member_info_success() { + // given + List routines = RoomFixture.routines(room); + DailyMemberCertification dailyMemberCertification = + RoomFixture.dailyMemberCertification(memberId, roomId, participant); + List imageUrls = new ArrayList<>(); + imageUrls.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); + imageUrls.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); + + given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(10).withMinute(6)); + given(clockHolder.date()).willReturn(today); + given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); + given(memberService.findMember(memberId)).willReturn(member1); + given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); + given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); + given(dailyMemberCertificationRepository.save(any(DailyMemberCertification.class))).willReturn( + dailyMemberCertification); + given(certificationRepository.saveAll(anyList())).willReturn(List.of()); + + // when + CertifiedMemberInfo certifiedMemberInfo = certificationService.getCertifiedMemberInfo(memberId, roomId, + imageUrls); + + // then + assertThat(certifiedMemberInfo.member().getNickname()).isEqualTo(member1.getNickname()); + assertThat(certifiedMemberInfo.bugType()).isEqualTo(BugType.MORNING); + assertThat(certifiedMemberInfo.date()).isEqualTo(today); + } + + @DisplayName("이미 인증되어 있는 방에서 루틴 인증 성공") + @Test + void already_certified_room_routine_success() { + // given + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today); + + given(clockHolder.date()).willReturn(today); + given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)).willReturn( + Optional.of(dailyRoomCertification)); + + CertifiedMemberInfo certifyInfo = CertificationsMapper.toCertifiedMemberInfo(clockHolder.date(), + BugType.MORNING, room, + member1); + + // when + certificationService.certifyRoom(certifyInfo); + + // then + verify(bugService).reward(any(Member.class), any(BugType.class), anyInt()); + } + + @DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공") + @Test + void not_certified_room_routine_success() { + // given + List dailyMemberCertifications = + RoomFixture.dailyMemberCertifications(roomId, participant); + + given(clockHolder.date()).willReturn(today); + given(certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today)) + .willReturn(dailyMemberCertifications); + given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3)); + + CertifiedMemberInfo certifyInfo = CertificationsMapper.toCertifiedMemberInfo(clockHolder.date(), + BugType.MORNING, room, + member1); + + // when + certificationService.certifyRoom(certifyInfo); + + // then + verify(bugService, times(3)).reward(any(Member.class), any(BugType.class), anyInt()); + assertThat(room.getExp()).isEqualTo(1); + assertThat(room.getLevel()).isEqualTo(2); + } +} diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java new file mode 100644 index 00000000..91e011da --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java @@ -0,0 +1,111 @@ +package com.moabam.api.application.room; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.dto.room.EnterRoomRequest; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@SpringBootTest +class RoomServiceConcurrencyTest { + + @Autowired + private RoomService roomService; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private ParticipantSearchRepository participantSearchRepository; + + @Autowired + private MemberRepository memberRepository; + + @DisplayName("입장 가능이 1명이 남은 상태에서 3명 동시 입장 요청") + @Test + void enter_room_concurrency_test() throws InterruptedException { + // given + Room room = Room.builder() + .title("테스트방") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(4) + .build(); + + for (int i = 0; i < 2; i++) { + room.increaseCurrentUserCount(); + } + + Room savedRoom = roomRepository.save(room); + + Member member1 = MemberFixture.member("qwe"); + Member member2 = MemberFixture.member("qwfe"); + Member member3 = MemberFixture.member("qff"); + memberRepository.saveAll(List.of(member1, member2, member3)); + + Participant participant1 = RoomFixture.participant(savedRoom, member1.getId()); + Participant participant2 = RoomFixture.participant(savedRoom, member2.getId()); + Participant participant3 = RoomFixture.participant(savedRoom, member3.getId()); + participantRepository.saveAll(List.of(participant1, participant2, participant3)); + + int threadCount = 3; + ExecutorService executorService = Executors.newFixedThreadPool(10); + CountDownLatch countDownLatch = new CountDownLatch(threadCount); + + EnterRoomRequest enterRoomRequest = new EnterRoomRequest(null); + List newMembers = new ArrayList<>(); + + // when + for (int i = 0; i < threadCount; i++) { + Member member = MemberFixture.member(String.valueOf(i + 100)); + newMembers.add(member); + memberRepository.save(member); + final Long memberId = member.getId(); + + executorService.submit(() -> { + try { + roomService.enterRoom(memberId, room.getId(), enterRoomRequest); + } finally { + countDownLatch.countDown(); + } + }); + } + + countDownLatch.await(); + + List actual = participantSearchRepository.findAllByRoomId(room.getId()); + Member newMember1 = memberRepository.findById(newMembers.get(0).getId()).orElseThrow(); + Member newMember2 = memberRepository.findById(newMembers.get(1).getId()).orElseThrow(); + Member newMember3 = memberRepository.findById(newMembers.get(2).getId()).orElseThrow(); + + // then + assertThat(actual).hasSize(4); + assertThat(newMember1.getCurrentMorningCount() + newMember2.getCurrentMorningCount() + + newMember3.getCurrentMorningCount()).isEqualTo(1); + + memberRepository.deleteAllById(List.of(member1.getId(), member2.getId(), member3.getId())); + memberRepository.deleteAll(newMembers); + } +} diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java new file mode 100644 index 00000000..2a37acfc --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -0,0 +1,163 @@ +package com.moabam.api.application.room; + +import static com.moabam.api.domain.room.RoomType.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.mapper.RoomMapper; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.global.error.exception.ForbiddenException; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class RoomServiceTest { + + @InjectMocks + private RoomService roomService; + + @Mock + private MemberService memberService; + + @Mock + private RoomRepository roomRepository; + + @Mock + private RoutineRepository routineRepository; + + @Mock + private ParticipantRepository participantRepository; + + @Mock + private ParticipantSearchRepository participantSearchRepository; + + @DisplayName("비밀번호 없는 방 생성 성공") + @Test + void create_room_no_password_success() { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + Member member = spy(MemberFixture.member()); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + + Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); + given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); + given(memberService.findMember(1L)).willReturn(member); + + // when + Long result = roomService.createRoom(1L, createRoomRequest); + + // then + verify(roomRepository).save(any(Room.class)); + verify(routineRepository).saveAll(ArgumentMatchers.anyList()); + verify(participantRepository).save(any(Participant.class)); + assertThat(result).isEqualTo(expectedRoom.getId()); + assertThat(expectedRoom.getPassword()).isNull(); + } + + @DisplayName("비밀번호 있는 방 생성 성공") + @Test + void create_room_with_password_success() { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + Member member = spy(MemberFixture.member()); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "재윤과 앵맹이의 방임", "1234", routines, MORNING, 10, 4); + + Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); + given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); + given(memberService.findMember(1L)).willReturn(member); + + // when + Long result = roomService.createRoom(1L, createRoomRequest); + + // then + verify(roomRepository).save(any(Room.class)); + verify(routineRepository).saveAll(ArgumentMatchers.anyList()); + verify(participantRepository).save(any(Participant.class)); + assertThat(result).isEqualTo(expectedRoom.getId()); + assertThat(expectedRoom.getPassword()).isEqualTo("1234"); + } + + @DisplayName("방장 위임 성공") + @Test + void room_manager_mandate_success() { + // given + Long managerId = 1L; + Long memberId = 2L; + + Member member = MemberFixture.member("1234"); + + Room room = spy(RoomFixture.room()); + given(room.getId()).willReturn(1L); + + Participant memberParticipant = RoomFixture.participant(room, memberId); + Participant managerParticipant = RoomFixture.participant(room, managerId); + managerParticipant.enableManager(); + + given(participantSearchRepository.findOne(memberId, room.getId())).willReturn( + Optional.of(memberParticipant)); + given(participantSearchRepository.findOne(managerId, room.getId())).willReturn( + Optional.of(managerParticipant)); + given(memberService.findMember(2L)).willReturn(member); + + // when + roomService.mandateManager(managerId, room.getId(), memberId); + + // then + assertThat(managerParticipant.isManager()).isFalse(); + assertThat(memberParticipant.isManager()).isTrue(); + } + + @DisplayName("방장 위임 실패 - 방장이 아닌 유저가 요청할 때") + @Test + void room_manager_mandate_fail() { + // given + Long managerId = 1L; + Long memberId = 2L; + + Room room = spy(RoomFixture.room()); + given(room.getId()).willReturn(1L); + + Participant memberParticipant = RoomFixture.participant(room, memberId); + Participant managerParticipant = RoomFixture.participant(room, managerId); + + given(participantSearchRepository.findOne(memberId, room.getId())).willReturn( + Optional.of(memberParticipant)); + given(participantSearchRepository.findOne(managerId, room.getId())).willReturn( + Optional.of(managerParticipant)); + + // when, then + assertThatThrownBy(() -> roomService.mandateManager(managerId, 1L, memberId)) + .isInstanceOf(ForbiddenException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/room/SearchServiceTest.java b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java new file mode 100644 index 00000000..6562caa2 --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java @@ -0,0 +1,417 @@ +package com.moabam.api.application.room; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.when; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoomSearchRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.GetAllRoomsResponse; +import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.api.dto.room.RoomsHistoryResponse; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class SearchServiceTest { + + @InjectMocks + private SearchService searchService; + + @Mock + private CertificationsSearchRepository certificationsSearchRepository; + + @Mock + private ParticipantSearchRepository participantSearchRepository; + + @Mock + private RoutineRepository routineRepository; + + @Mock + private RoomSearchRepository roomSearchRepository; + + @Mock + private MemberService memberService; + + @Mock + private CertificationService certificationService; + + @Mock + private RoomRepository roomRepository; + + @Mock + private ClockHolder clockHolder; + + @DisplayName("유저가 참여중인 방 목록 조회 성공") + @Test + void get_my_rooms_success() { + // given + LocalDate today = LocalDate.now(); + Long memberId = 1L; + Room room1 = spy(RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10)); + Room room2 = spy(RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9)); + Room room3 = spy(RoomFixture.room("밤 - 첫 번째 방", RoomType.NIGHT, 22)); + + when(room1.getId()).thenReturn(1L); + when(room2.getId()).thenReturn(2L); + when(room3.getId()).thenReturn(3L); + + Participant participant1 = RoomFixture.participant(room1, memberId); + Participant participant2 = RoomFixture.participant(room2, memberId); + Participant participant3 = RoomFixture.participant(room3, memberId); + List participants = List.of(participant1, participant2, participant3); + + given(participantSearchRepository.findNotDeletedAllByMemberId(memberId)).willReturn(participants); + given(certificationService.existsMemberCertification(memberId, room1.getId(), today)).willReturn(true); + given(certificationService.existsMemberCertification(memberId, room2.getId(), today)).willReturn(false); + given(certificationService.existsMemberCertification(memberId, room3.getId(), today)).willReturn(true); + + given(certificationService.existsRoomCertification(room1.getId(), today)).willReturn(true); + given(certificationService.existsRoomCertification(room2.getId(), today)).willReturn(false); + given(certificationService.existsRoomCertification(room3.getId(), today)).willReturn(false); + + given(clockHolder.date()).willReturn(LocalDate.now()); + + // when + MyRoomsResponse myRooms = searchService.getMyRooms(memberId); + + // then + assertThat(myRooms.participatingRooms()).hasSize(3); + + assertThat(myRooms.participatingRooms().get(0).isMemberCertifiedToday()).isTrue(); + assertThat(myRooms.participatingRooms().get(0).isRoomCertifiedToday()).isTrue(); + + assertThat(myRooms.participatingRooms().get(1).isMemberCertifiedToday()).isFalse(); + assertThat(myRooms.participatingRooms().get(1).isRoomCertifiedToday()).isFalse(); + + assertThat(myRooms.participatingRooms().get(2).isMemberCertifiedToday()).isTrue(); + assertThat(myRooms.participatingRooms().get(2).isRoomCertifiedToday()).isFalse(); + } + + @DisplayName("방 참여 기록 조회 성공") + @Test + void get_my_join_history_success() { + // given + LocalDateTime today = LocalDateTime.now(); + Long memberId = 1L; + Room room1 = spy(RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10)); + Room room2 = spy(RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9)); + Room room3 = RoomFixture.room("밤 - 첫 번째 방", RoomType.NIGHT, 22); + + when(room1.getId()).thenReturn(1L); + when(room2.getId()).thenReturn(2L); + + Participant participant1 = RoomFixture.participant(room1, memberId); + Participant participant2 = RoomFixture.participant(room2, memberId); + Participant participant3 = spy(RoomFixture.participant(room3, memberId)); + participant3.removeRoom(); + List participants = List.of(participant1, participant2, participant3); + + when(participant3.getDeletedAt()).thenReturn(today); + when(participant3.getDeletedRoomTitle()).thenReturn("밤 - 첫 번째 방"); + given(participantSearchRepository.findAllByMemberId(memberId)).willReturn(participants); + + // when + RoomsHistoryResponse response = searchService.getJoinHistory(memberId); + + // then + assertThat(response.roomHistory()).hasSize(3); + + assertThat(response.roomHistory().get(0).deletedAt()).isNull(); + assertThat(response.roomHistory().get(0).title()).isEqualTo(room1.getTitle()); + + assertThat(response.roomHistory().get(1).deletedAt()).isNull(); + assertThat(response.roomHistory().get(1).title()).isEqualTo(room2.getTitle()); + + assertThat(response.roomHistory().get(2).deletedAt()).isNotNull(); + assertThat(response.roomHistory().get(2).title()).isEqualTo(participant3.getDeletedRoomTitle()); + } + + @DisplayName("아침, 저녁 전체 방 조회 성공, 첫 번째 조회, 다음 페이지 있음") + @Test + void search_all_morning_night_rooms_success() { + // given + Room room1 = spy(RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234")); + Room room2 = spy(RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9)); + Room room3 = spy(RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22)); + Room room4 = spy(RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7)); + Room room5 = spy(RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869")); + Room room6 = spy(RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8)); + Room room7 = spy(RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20)); + Room room8 = spy(RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236")); + Room room9 = spy(RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4)); + Room room10 = spy(RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979")); + Room room11 = spy(RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22)); + Room room12 = spy(RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10)); + Room room13 = spy(RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2)); + Room room14 = spy(RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21)); + + given(room1.getId()).willReturn(1L); + given(room2.getId()).willReturn(2L); + given(room3.getId()).willReturn(3L); + given(room4.getId()).willReturn(4L); + given(room5.getId()).willReturn(5L); + given(room6.getId()).willReturn(6L); + given(room7.getId()).willReturn(7L); + given(room8.getId()).willReturn(8L); + given(room9.getId()).willReturn(9L); + given(room10.getId()).willReturn(10L); + given(room11.getId()).willReturn(11L); + given(room12.getId()).willReturn(12L); + given(room13.getId()).willReturn(13L); + given(room14.getId()).willReturn(14L); + + List rooms = List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11); + + Routine routine1 = spy(RoomFixture.routine(room1, "방1의 루틴1")); + Routine routine2 = spy(RoomFixture.routine(room1, "방1의 루틴2")); + + Routine routine3 = spy(RoomFixture.routine(room2, "방2의 루틴1")); + Routine routine4 = spy(RoomFixture.routine(room2, "방2의 루틴2")); + + Routine routine5 = spy(RoomFixture.routine(room3, "방3의 루틴1")); + Routine routine6 = spy(RoomFixture.routine(room3, "방3의 루틴2")); + + Routine routine7 = spy(RoomFixture.routine(room4, "방4의 루틴1")); + Routine routine8 = spy(RoomFixture.routine(room4, "방4의 루틴2")); + + Routine routine9 = spy(RoomFixture.routine(room5, "방5의 루틴1")); + Routine routine10 = spy(RoomFixture.routine(room5, "방5의 루틴2")); + + Routine routine11 = spy(RoomFixture.routine(room6, "방6의 루틴1")); + Routine routine12 = spy(RoomFixture.routine(room6, "방6의 루틴2")); + + Routine routine13 = spy(RoomFixture.routine(room7, "방7의 루틴1")); + Routine routine14 = spy(RoomFixture.routine(room7, "방7의 루틴2")); + + Routine routine15 = spy(RoomFixture.routine(room8, "방8의 루틴1")); + Routine routine16 = spy(RoomFixture.routine(room8, "방8의 루틴2")); + + Routine routine17 = spy(RoomFixture.routine(room9, "방9의 루틴1")); + Routine routine18 = spy(RoomFixture.routine(room9, "방9의 루틴2")); + + Routine routine19 = spy(RoomFixture.routine(room10, "방10의 루틴1")); + Routine routine20 = spy(RoomFixture.routine(room10, "방10의 루틴2")); + + Routine routine21 = spy(RoomFixture.routine(room11, "방11의 루틴1")); + Routine routine22 = spy(RoomFixture.routine(room11, "방11의 루틴2")); + + Routine routine23 = spy(RoomFixture.routine(room12, "방12의 루틴1")); + Routine routine24 = spy(RoomFixture.routine(room12, "방12의 루틴2")); + + Routine routine25 = spy(RoomFixture.routine(room13, "방13의 루틴1")); + Routine routine26 = spy(RoomFixture.routine(room13, "방13의 루틴2")); + + Routine routine27 = spy(RoomFixture.routine(room14, "방14의 루틴1")); + Routine routine28 = spy(RoomFixture.routine(room14, "방14의 루틴2")); + + given(routine1.getId()).willReturn(1L); + given(routine2.getId()).willReturn(2L); + given(routine3.getId()).willReturn(3L); + given(routine4.getId()).willReturn(4L); + given(routine5.getId()).willReturn(5L); + given(routine6.getId()).willReturn(6L); + given(routine7.getId()).willReturn(7L); + given(routine8.getId()).willReturn(8L); + given(routine9.getId()).willReturn(9L); + given(routine10.getId()).willReturn(10L); + given(routine11.getId()).willReturn(11L); + given(routine12.getId()).willReturn(12L); + given(routine13.getId()).willReturn(13L); + given(routine14.getId()).willReturn(14L); + given(routine15.getId()).willReturn(15L); + given(routine16.getId()).willReturn(16L); + given(routine17.getId()).willReturn(17L); + given(routine18.getId()).willReturn(18L); + given(routine19.getId()).willReturn(19L); + given(routine20.getId()).willReturn(20L); + + List routines = List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, + routine9, routine10, routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, + routine19, routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, + routine28); + + given(roomSearchRepository.findAllWithNoOffset(null, null)).willReturn(rooms); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); + + // when + GetAllRoomsResponse getAllRoomsResponse = searchService.getAllRooms(null, null); + + // then + assertThat(getAllRoomsResponse.hasNext()).isTrue(); + assertThat(getAllRoomsResponse.rooms()).hasSize(10); + assertThat(getAllRoomsResponse.rooms().get(0).id()).isEqualTo(1L); + assertThat(getAllRoomsResponse.rooms().get(9).id()).isEqualTo(10L); + } + + @DisplayName("아침, 저녁 전체 방 조회 성공, 마지막 페이 조회, 다음 페이지 없음") + @Test + void search_last_page_all_morning_night_rooms_success() { + // given + Room room11 = spy(RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22)); + Room room12 = spy(RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10)); + Room room13 = spy(RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2)); + Room room14 = spy(RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21)); + + given(room11.getId()).willReturn(11L); + given(room12.getId()).willReturn(12L); + given(room13.getId()).willReturn(13L); + given(room14.getId()).willReturn(14L); + + List rooms = List.of(room11, room12, room13, room14); + + Routine routine21 = spy(RoomFixture.routine(room11, "방11의 루틴1")); + Routine routine22 = spy(RoomFixture.routine(room11, "방11의 루틴2")); + + Routine routine23 = spy(RoomFixture.routine(room12, "방12의 루틴1")); + Routine routine24 = spy(RoomFixture.routine(room12, "방12의 루틴2")); + + Routine routine25 = spy(RoomFixture.routine(room13, "방13의 루틴1")); + Routine routine26 = spy(RoomFixture.routine(room13, "방13의 루틴2")); + + Routine routine27 = spy(RoomFixture.routine(room14, "방14의 루틴1")); + Routine routine28 = spy(RoomFixture.routine(room14, "방14의 루틴2")); + + given(routine21.getId()).willReturn(21L); + given(routine22.getId()).willReturn(22L); + given(routine23.getId()).willReturn(23L); + given(routine24.getId()).willReturn(24L); + given(routine25.getId()).willReturn(25L); + given(routine26.getId()).willReturn(26L); + given(routine27.getId()).willReturn(27L); + given(routine28.getId()).willReturn(28L); + + List routines = List.of(routine21, routine22, routine23, routine24, routine25, routine26, routine27, + routine28); + + given(roomSearchRepository.findAllWithNoOffset(null, 10L)).willReturn(rooms); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); + + // when + GetAllRoomsResponse getAllRoomsResponse = searchService.getAllRooms(null, 10L); + + // then + assertThat(getAllRoomsResponse.hasNext()).isFalse(); + assertThat(getAllRoomsResponse.rooms()).hasSize(4); + assertThat(getAllRoomsResponse.rooms().get(0).id()).isEqualTo(11L); + assertThat(getAllRoomsResponse.rooms().get(3).id()).isEqualTo(14L); + } + + @DisplayName("전체 방 제목, 방장 이름, 루틴 내용으로 검색 성공 - 최초 조회") + @Test + void search_room_by_title_manager_nickname_routine_success() { + // given + Room room1 = spy(RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234")); + Room room2 = spy(RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9)); + Room room3 = spy(RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22)); + Room room4 = spy(RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7)); + Room room5 = spy(RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869")); + Room room6 = spy(RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8)); + Room room7 = spy(RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20)); + Room room8 = spy(RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236")); + Room room9 = spy(RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4)); + Room room10 = spy(RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979")); + Room room11 = spy(RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22)); + Room room12 = spy(RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10)); + Room room13 = spy(RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2)); + Room room14 = spy(RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21)); + + given(room4.getId()).willReturn(4L); + given(room5.getId()).willReturn(5L); + given(room6.getId()).willReturn(6L); + given(room7.getId()).willReturn(7L); + given(room8.getId()).willReturn(8L); + given(room9.getId()).willReturn(9L); + given(room10.getId()).willReturn(10L); + given(room11.getId()).willReturn(11L); + given(room12.getId()).willReturn(12L); + given(room13.getId()).willReturn(13L); + given(room14.getId()).willReturn(14L); + + List rooms = List.of(room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, room14); + + Routine routine9 = spy(RoomFixture.routine(room5, "방5의 루틴1")); + Routine routine10 = spy(RoomFixture.routine(room5, "방5의 루틴2")); + + Routine routine11 = spy(RoomFixture.routine(room6, "방6의 루틴1")); + Routine routine12 = spy(RoomFixture.routine(room6, "방6의 루틴2")); + + Routine routine13 = spy(RoomFixture.routine(room7, "방7의 루틴1")); + Routine routine14 = spy(RoomFixture.routine(room7, "방7의 루틴2")); + + Routine routine15 = spy(RoomFixture.routine(room8, "방8의 루틴1")); + Routine routine16 = spy(RoomFixture.routine(room8, "방8의 루틴2")); + + Routine routine17 = spy(RoomFixture.routine(room9, "방9의 루틴1")); + Routine routine18 = spy(RoomFixture.routine(room9, "방9의 루틴2")); + + Routine routine19 = spy(RoomFixture.routine(room10, "방10의 루틴1")); + Routine routine20 = spy(RoomFixture.routine(room10, "방10의 루틴2")); + + Routine routine21 = spy(RoomFixture.routine(room11, "방11의 루틴1")); + Routine routine22 = spy(RoomFixture.routine(room11, "방11의 루틴2")); + + Routine routine23 = spy(RoomFixture.routine(room12, "방12의 루틴1")); + Routine routine24 = spy(RoomFixture.routine(room12, "방12의 루틴2")); + + Routine routine25 = spy(RoomFixture.routine(room13, "방13의 루틴1")); + Routine routine26 = spy(RoomFixture.routine(room13, "방13의 루틴2")); + + Routine routine27 = spy(RoomFixture.routine(room14, "방14의 루틴1")); + Routine routine28 = spy(RoomFixture.routine(room14, "방14의 루틴2")); + + given(routine9.getId()).willReturn(9L); + given(routine10.getId()).willReturn(10L); + given(routine11.getId()).willReturn(11L); + given(routine12.getId()).willReturn(12L); + given(routine13.getId()).willReturn(13L); + given(routine14.getId()).willReturn(14L); + given(routine15.getId()).willReturn(15L); + given(routine16.getId()).willReturn(16L); + given(routine17.getId()).willReturn(17L); + given(routine18.getId()).willReturn(18L); + given(routine19.getId()).willReturn(19L); + given(routine20.getId()).willReturn(20L); + given(routine21.getId()).willReturn(21L); + given(routine22.getId()).willReturn(22L); + given(routine23.getId()).willReturn(23L); + given(routine24.getId()).willReturn(24L); + given(routine25.getId()).willReturn(25L); + given(routine26.getId()).willReturn(26L); + + List routines = List.of(routine9, routine10, routine11, routine12, routine13, routine14, routine15, + routine16, routine17, routine18, routine19, routine20, routine21, routine22, routine23, routine24, + routine25, routine26, routine27, routine28); + + given(roomRepository.searchByKeyword("번째")).willReturn(rooms); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); + + // when + GetAllRoomsResponse getAllRoomsResponse = searchService.searchRooms("번째", null, null); + + // then + assertThat(getAllRoomsResponse.hasNext()).isTrue(); + assertThat(getAllRoomsResponse.rooms()).hasSize(10); + } +} diff --git a/src/test/java/com/moabam/api/domain/bug/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java new file mode 100644 index 00000000..4fe62d0d --- /dev/null +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -0,0 +1,79 @@ +package com.moabam.api.domain.bug; + +import static com.moabam.support.fixture.BugFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +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 com.moabam.global.error.exception.BadRequestException; + +class BugTest { + + @DisplayName("벌레 개수가 음수이면 예외가 발생한다.") + @ParameterizedTest + @CsvSource({ + "-10, 10, 10", + "10, -10, 10", + "10, 10, -10", + }) + void validate_bug_count_exception(int morningBug, int nightBug, int goldenBug) { + Bug.BugBuilder bugBuilder = Bug.builder() + .morningBug(morningBug) + .nightBug(nightBug) + .goldenBug(goldenBug); + + assertThatThrownBy(bugBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("벌레 개수는 0 이상이어야 합니다."); + } + + @DisplayName("벌레를 사용한다.") + @Nested + class Use { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Bug bug = bug(); + + // when + bug.use(BugType.MORNING, 5); + + // then + assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG - 5); + } + + @DisplayName("벌레 개수가 부족하면 사용할 수 없다.") + @Test + void not_enough_exception() { + // given + Bug bug = bug(); + + // when, then + assertThatThrownBy(() -> bug.use(BugType.MORNING, 50)) + .isInstanceOf(BadRequestException.class) + .hasMessage("보유한 벌레가 부족합니다."); + } + } + + @DisplayName("해당 벌레 타입의 개수를 증가한다.") + @Test + void increase_bug_success() { + // given + Bug bug = bug(); + + // when + bug.increase(BugType.MORNING, 5); + bug.increase(BugType.NIGHT, 5); + bug.increase(BugType.GOLDEN, 5); + + // then + assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG + 5); + assertThat(bug.getNightBug()).isEqualTo(NIGHT_BUG + 5); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java new file mode 100644 index 00000000..caca0818 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -0,0 +1,62 @@ +package com.moabam.api.domain.coupon; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.CouponFixture; + +class CouponTest { + + @DisplayName("쿠폰이 성공적으로 생성된다. - Coupon") + @Test + void coupon_success() { + // Given + LocalDate startAt = LocalDate.of(2023, 2, 1); + LocalDate openAt = LocalDate.of(2023, 1, 1); + + // When + Coupon actual = Coupon.builder() + .name("couponName") + .point(10) + .type(CouponType.MORNING) + .maxCount(100) + .startAt(startAt) + .openAt(openAt) + .adminId(1L) + .build(); + + // Then + assertThat(actual.getName()).isEqualTo("couponName"); + assertThat(actual.getDescription()).isBlank(); + assertThat(actual.getPoint()).isEqualTo(10); + assertThat(actual.getMaxCount()).isEqualTo(100); + assertThat(actual.getType()).isEqualTo(CouponType.MORNING); + assertThat(actual.getStartAt()).isEqualTo(startAt); + assertThat(actual.getOpenAt()).isEqualTo(openAt); + assertThat(actual.getAdminId()).isEqualTo(1L); + } + + @DisplayName("쿠폰 보너스 포인트가 1보다 작다. - BadRequestException") + @Test + void validatePoint_BadRequestException() { + // When& Then + assertThatThrownBy(() -> CouponFixture.coupon(0, 1)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_POINT.getMessage()); + } + + @DisplayName("쿠폰 재고가 1보다 작다. - BadRequestException") + @Test + void validateStock_BadRequestException() { + // When& Then + assertThatThrownBy(() -> CouponFixture.coupon(1, 0)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_STOCK.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java new file mode 100644 index 00000000..cf774ca9 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java @@ -0,0 +1,52 @@ +package com.moabam.api.domain.coupon; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.bug.BugType; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +class CouponTypeTest { + + @DisplayName("존재하는 쿠폰을 성공적으로 가져온다. - CouponType") + @Test + void from_success() { + // When + CouponType actual = CouponType.from(CouponType.GOLDEN.getName()); + + // Then + assertThat(actual).isEqualTo(CouponType.GOLDEN); + } + + @DisplayName("존재하지 않는 쿠폰을 가져온다. - NotFoundException") + @Test + void from_NotFoundException() { + // When & Then + assertThatThrownBy(() -> CouponType.from("Not-Coupon")) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + } + + @DisplayName("할인 쿠폰인 확인한다. - Boolean") + @Test + void isDiscount_true() { + // When + boolean actual = CouponType.DISCOUNT.isDiscount(); + + // Then + assertThat(actual).isTrue(); + } + + @DisplayName("벌레 타입을 반환한다. - CouponType") + @Test + void getBugType_success() { + // When + BugType actual = CouponType.GOLDEN.getBugType(); + + // Then + assertThat(actual).isEqualTo(BugType.GOLDEN); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java new file mode 100644 index 00000000..13bcd9e5 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java @@ -0,0 +1,25 @@ +package com.moabam.api.domain.coupon; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.support.fixture.CouponFixture; + +class CouponWalletTest { + + @DisplayName("쿠폰 지갑 엔티티를 성공적으로 생성한다. - Void") + @Test + void couponWallet_success() { + // Given + Coupon coupon = CouponFixture.coupon("CouponName", 1, 2); + + // When + CouponWallet actual = CouponWallet.create(1L, coupon); + + // Then + assertThat(actual.getMemberId()).isEqualTo(1L); + assertThat(actual.getCoupon().getName()).isEqualTo(coupon.getName()); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java new file mode 100644 index 00000000..f63cc680 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java @@ -0,0 +1,119 @@ +package com.moabam.api.domain.coupon.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +@ExtendWith(MockitoExtension.class) +class CouponManageRepositoryTest { + + @InjectMocks + CouponManageRepository couponManageRepository; + + @Mock + ZSetRedisRepository zSetRedisRepository; + + @Mock + ValueRedisRepository valueRedisRepository; + + @DisplayName("쿠폰 대기열에 사용자가 성공적으로 등록된다. - Void") + @Test + void addIfAbsentQueue_success() { + // When + couponManageRepository.addIfAbsentQueue("couponName", 1L, 1); + + // Then + verify(zSetRedisRepository).addIfAbsent(any(String.class), any(Long.class), any(double.class), any(int.class)); + } + + @DisplayName("쿠폰명이 Null인 대기열에 사용자를 등록한다.- NullPointerException") + @Test + void addIfAbsentQueue_couponName_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.addIfAbsentQueue(null, 1L, 1)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰 대기열에 사용자 ID가 Null인 사용자를 등록한다. - NullPointerException") + @Test + void addIfAbsentQueue_memberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.addIfAbsentQueue("couponName", null, 1)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰 대기열에서 성공적으로 10명을 조회한다. - Set") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Object") + @ParameterizedTest + void range_success(Set values) { + // Given + given(zSetRedisRepository.range(any(String.class), any(long.class), any(long.class))).willReturn(values); + + // When + Set actual = couponManageRepository.rangeQueue("couponName", 0, 10); + + // Then + assertThat(actual).hasSize(10); + } + + @DisplayName("쿠폰명이 Null인 대기열에서 사용자를 조회한다. - NullPointerException") + @Test + void range_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.rangeQueue(null, 0, 10)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("특정 사용자의 쿠폰 순위를 정상적으로 조회한다. - int") + @Test + void rankQueue_success() { + // Given + given(zSetRedisRepository.rank(any(String.class), any(Long.class))).willReturn(1L); + + // When + int actual = couponManageRepository.rankQueue("couponName", 1L); + + // Then + assertThat(actual).isEqualTo(1); + } + + @DisplayName("쿠폰명이 Null인 특정 사용자 쿠폰 순위를 조회한다. - int") + @Test + void rankQueue_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.rankQueue(null, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰 대기열을 성공적으로 삭제한다. - Void") + @Test + void deleteQueue_success() { + // When + couponManageRepository.deleteQueue("couponName"); + + // Then + verify(valueRedisRepository).delete(any(String.class)); + } + + @DisplayName("쿠폰명이 Null인 대기열을 삭제한다. - NullPointerException") + @Test + void deleteQueue_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.deleteQueue(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java new file mode 100644 index 00000000..41d5cf78 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -0,0 +1,104 @@ +package com.moabam.api.domain.coupon.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.global.config.JpaConfig; +import com.moabam.support.fixture.CouponFixture; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import({JpaConfig.class, CouponSearchRepository.class}) +class CouponSearchRepositoryTest { + + @Autowired + CouponRepository couponRepository; + + @Autowired + CouponSearchRepository couponSearchRepository; + + @DisplayName("발급 가능한 쿠폰을 성공적으로 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void findAllByStatus_success(List coupons) { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); + LocalDate now = LocalDate.of(2023, 7, 1); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(1); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 7, 1)); + } + + @DisplayName("모든 쿠폰을 발급 가능 날짜 순으로 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void findAllByStatus_order_by_startAt(List coupons) { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); + LocalDate now = LocalDate.now(); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(coupons.size()); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 3, 1)); + } + + @DisplayName("발급 가능한 쿠폰 포함하여 쿠폰 정보 오픈 중인 쿠폰들을 발급 가능 날짜 순으로 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void findAllByStatus_opened_order_by_startAt(List coupons) { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, false); + LocalDate now = LocalDate.of(2023, 7, 1); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(3); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 7, 1)); + } + + @DisplayName("종료된 쿠폰들을 발급 가능 날짜 순으로 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void findAllByStatus_ended_order_by_startAt(List coupons) { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, true); + LocalDate now = LocalDate.of(2023, 8, 1); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(5); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 3, 1)); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java new file mode 100644 index 00000000..2770be82 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -0,0 +1,126 @@ +package com.moabam.api.domain.coupon.repository; + +import static com.moabam.support.fixture.CouponFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.CouponFixture; + +@QuerydslRepositoryTest +class CouponWalletSearchRepositoryTest { + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private CouponWalletRepository couponWalletRepository; + + @Autowired + private CouponWalletSearchRepository couponWalletSearchRepository; + + @DisplayName("나의 쿠폰함의 특정 쿠폰을 조회한다. - List") + @Test + void findAllByIdAndMemberId_success() { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // When + List actual = couponWalletSearchRepository + .findAllByIdAndMemberId(couponWallet.getId(), 1L); + + // Then + assertThat(actual).hasSize(1); + assertThat(actual.get(0).getCoupon().getName()).isEqualTo(coupon.getName()); + assertThat(actual.get(0).getMemberId()).isEqualTo(couponWallet.getMemberId()); + } + + @DisplayName("ID가 1인 회원은 쿠폰 1개를 가지고 있다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") + @ParameterizedTest + void findAllByIdAndMemberId_Id1_success(List couponWallets) { + // Given + couponWallets.forEach(couponWallet -> { + Coupon coupon = couponRepository.save(couponWallet.getCoupon()); + couponWalletRepository.save(CouponWallet.create(couponWallet.getMemberId(), coupon)); + }); + + // When + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(null, 1L); + + // Then + assertThat(actual).hasSize(1); + } + + @DisplayName("ID가 2인 회원은 쿠폰 ID가 777인 쿠폰을 가지고 있지 않다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") + @ParameterizedTest + void findAllByIdAndMemberId_Id2_notCouponId777(List couponWallets) { + // Given + couponWallets.forEach(couponWallet -> { + Coupon coupon = couponRepository.save(couponWallet.getCoupon()); + couponWalletRepository.save(CouponWallet.create(couponWallet.getMemberId(), coupon)); + }); + + // When + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(777L, 2L); + + // Then + assertThat(actual).isEmpty(); + } + + @DisplayName("ID가 3인 회원은 쿠폰 3개를 가지고 있다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") + @ParameterizedTest + void findAllByIdAndMemberId_Id3_success(List couponWallets) { + // Given + couponWallets.forEach(couponWallet -> { + Coupon coupon = couponRepository.save(couponWallet.getCoupon()); + couponWalletRepository.save(CouponWallet.create(couponWallet.getMemberId(), coupon)); + }); + + // When + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(null, 3L); + + // Then + assertThat(actual).hasSize(3); + } + + @DisplayName("회원의 특정 쿠폰 지갑을 성공적으로 조회한다. - CouponWallet") + @Test + void findByIdAndMemberId_success() { + // given + Coupon coupon = couponRepository.save(discount1000Coupon()); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // when + CouponWallet actual = couponWalletSearchRepository.findByIdAndMemberId(couponWallet.getId(), 1L) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); + + // then + assertThat(actual.getCoupon()).isEqualTo(coupon); + } + + @DisplayName("특정 회원의 특정 쿠폰 지갑이 조회되지 않는다. - CouponWallet") + @Test + void findByIdAndMemberId_notFound() { + // When + Optional actual = couponWalletSearchRepository.findByIdAndMemberId(1L, 1L); + + // Then + assertThat(actual).isEmpty(); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java new file mode 100644 index 00000000..1abd753b --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -0,0 +1,111 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.common.util.BaseImageUrl.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.api.domain.room.RoomType; +import com.moabam.support.fixture.MemberFixture; + +class MemberTest { + + String socialId = "1"; + String nickname = "밥세공기"; + String profileImage = "kakao/profile/url"; + + @DisplayName("회원 생성 성공") + @Test + void create_member_success() { + // When + Then + assertThatNoException().isThrownBy(() -> Member.builder() + .socialId(socialId) + .bug(Bug.builder().build()) + .build()); + } + + @DisplayName("프로필 이미지 없이 회원 생성 성공") + @Test + void create_member_noImage_success() { + // When + Then + assertThatNoException().isThrownBy(() -> { + Member member = Member.builder() + .socialId(socialId) + .bug(Bug.builder().build()) + .build(); + + assertAll( + () -> assertThat(member.getProfileImage()).isEqualTo(IMAGE_DOMAIN + MEMBER_PROFILE_URL), + () -> assertThat(member.getRole()).isEqualTo(Role.USER), + () -> assertThat(member.getBug().getNightBug()).isZero(), + () -> assertThat(member.getBug().getGoldenBug()).isZero(), + () -> assertThat(member.getBug().getMorningBug()).isZero(), + () -> assertThat(member.getTotalCertifyCount()).isZero(), + () -> assertThat(member.getReportCount()).isZero(), + () -> assertThat(member.getCurrentMorningCount()).isZero(), + () -> assertThat(member.getCurrentNightCount()).isZero() + ); + }); + } + + @DisplayName("소셜ID에 따른 회원 생성 실패") + @Test + void creat_member_failBy_socialId() { + // When + Then + assertThatThrownBy(Member.builder()::build) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("닉네임에 따른 회원 생성 실패") + @Test + void create_member_failBy_nickname() { + // When + Then + assertThatThrownBy(Member.builder() + .socialId(socialId)::build) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("멤버 방 출입 기능 테스트") + @Nested + class MemberRoomInOut { + + @DisplayName("회원 방 입장 성공") + @Test + void member_room_enter_success() { + // given + Member member = MemberFixture.member(); + + // when + int beforeMorningCount = member.getCurrentMorningCount(); + member.enterRoom(RoomType.MORNING); + + int beforeNightCount = member.getCurrentNightCount(); + member.enterRoom(RoomType.NIGHT); + + // then + assertThat(member.getCurrentMorningCount()).isEqualTo(beforeMorningCount + 1); + assertThat(member.getCurrentMorningCount()).isEqualTo(beforeNightCount + 1); + } + + @DisplayName("회원 방 탈출 성공") + @Test + void member_room_exit_success() { + // given + Member member = MemberFixture.member(); + + // when + member.exitRoom(RoomType.MORNING); + member.exitRoom(RoomType.NIGHT); + + // then + assertThat(member.getCurrentMorningCount()).isZero(); + assertThat(member.getCurrentNightCount()).isZero(); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/item/ItemTest.java b/src/test/java/com/moabam/api/domain/item/ItemTest.java new file mode 100644 index 00000000..326dd553 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/item/ItemTest.java @@ -0,0 +1,117 @@ +package com.moabam.api.domain.item; + +import static com.moabam.support.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +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 com.moabam.api.domain.bug.BugType; +import com.moabam.global.error.exception.BadRequestException; + +class ItemTest { + + @DisplayName("아이템을 생성한다.") + @Nested + class Create { + + @DisplayName("해금 레벨은 기본 1로 설정한다.") + @Test + void default_unlock_level() { + // given, when + Item item = nightMageSkin(); + + // then + assertThat(item.getUnlockLevel()).isEqualTo(1); + } + + @DisplayName("가격이 음수이면 예외가 발생한다.") + @ParameterizedTest + @CsvSource({ + "-10, 10", + "10, -10", + }) + void price_exception(int bugPrice, int goldenBugPrice) { + Item.ItemBuilder itemBuilder = morningSantaSkin() + .bugPrice(bugPrice) + .goldenBugPrice(goldenBugPrice); + + assertThatThrownBy(itemBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("가격은 0 이상이어야 합니다."); + } + + @DisplayName("레벨이 1보다 작으면 예외가 발생한다.") + @Test + void level_exception() { + Item.ItemBuilder itemBuilder = morningSantaSkin() + .unlockLevel(-1); + + assertThatThrownBy(itemBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("레벨은 1 이상이어야 합니다."); + } + } + + @DisplayName("해당 벌레 타입의 가격을 조회한다.") + @ParameterizedTest + @CsvSource({ + "MORNING, 10", + "GOLDEN, 5", + }) + void get_price_success(BugType bugType, int expected) { + // given + Item item = morningSantaSkin() + .bugPrice(10) + .goldenBugPrice(5) + .build(); + + // when, then + assertThat(item.getPrice(bugType)).isEqualTo(expected); + } + + @DisplayName("아이템 구매 가능 여부를 검증한다.") + @Nested + class ValidatePurchasable { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Item item = nightMageSkin(); + + // when, then + assertDoesNotThrow(() -> item.validatePurchasable(BugType.NIGHT, 5)); + } + + @DisplayName("해금 레벨이 높으면 구매할 수 없다.") + @Test + void unlocked_exception() { + // given + Item item = morningSantaSkin() + .unlockLevel(10) + .build(); + + // when, then + assertThatThrownBy(() -> item.validatePurchasable(BugType.MORNING, 5)) + .isInstanceOf(BadRequestException.class) + .hasMessage("아이템 해금 레벨이 높습니다."); + } + + @DisplayName("벌레 타입이 맞지 않으면 구매할 수 없다.") + @Test + void bug_type_exception() { + // given + Item item = nightMageSkin(); + + // when, then + assertThatThrownBy(() -> item.validatePurchasable(BugType.MORNING, 5)) + .isInstanceOf(BadRequestException.class) + .hasMessage("해당 벌레 타입으로는 구매할 수 없는 아이템입니다."); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java new file mode 100644 index 00000000..b609e309 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -0,0 +1,169 @@ +package com.moabam.api.domain.item.repository; + +import static com.moabam.support.fixture.InventoryFixture.*; +import static com.moabam.support.fixture.ItemFixture.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.RoomType; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.InventoryFixture; +import com.moabam.support.fixture.ItemFixture; +import com.moabam.support.fixture.MemberFixture; + +@QuerydslRepositoryTest +class InventorySearchRepositoryTest { + + @Autowired + InventorySearchRepository inventorySearchRepository; + + @Autowired + MemberRepository memberRepository; + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @DisplayName("인벤토리 아이템 목록을 조회한다.") + @Nested + class FindItems { + + @DisplayName("해당 타입의 아이템 목록을 구매일 순으로 정렬한다.") + @Test + void sorted_by_created_at_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + inventoryRepository.save(inventory(memberId, morningKillerSkin)); + Item nightMageSkin = itemRepository.save(nightMageSkin()); + inventoryRepository.save(inventory(memberId, nightMageSkin)); + + // when + List actual = inventorySearchRepository.findItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningKillerSkin, morningSantaSkin); + } + + @DisplayName("해당 타입의 아이템이 없으면 빈 목록을 조회한다.") + @Test + void empty_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + inventoryRepository.save(inventory(memberId, morningKillerSkin)); + + // when + List actual = inventorySearchRepository.findItems(memberId, ItemType.NIGHT); + + // then + assertThat(actual).isEmpty(); + } + } + + @DisplayName("인벤토리를 조회한다.") + @Test + void find_one_success() { + // given + Member member = memberRepository.save(member("999")); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); + + // when + Optional actual = inventorySearchRepository.findOne(member.getId(), item.getId()); + + // then + assertThat(actual).isPresent().contains(inventory); + } + + @DisplayName("현재 적용된 인벤토리를 조회한다.") + @Test + void find_default_success() { + // given + Member member = memberRepository.save(member("11314")); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); + inventory.select(member); + + // when + Optional actual = inventorySearchRepository.findDefault(member.getId(), inventory.getItemType()); + + // then + assertThat(actual).isPresent().contains(inventory); + } + + @DisplayName("여러 회원의 밤 타입에 적용된 인벤토리를 조회한다.") + @Test + void find_all_default_type_night_success() { + // given + Member member1 = memberRepository.save(member("625")); + Member member2 = memberRepository.save(member("255")); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory1 = inventoryRepository.save(inventory(member1.getId(), item)); + Inventory inventory2 = inventoryRepository.save(inventory(member2.getId(), item)); + inventory1.select(member1); + inventory2.select(member2); + + // when + List actual = inventorySearchRepository.findDefaultInventories(List.of(member1.getId(), + member2.getId()), RoomType.NIGHT.name()); + + // then + assertThat(actual).hasSize(2); + assertThat(actual.get(0).getItem().getName()).isEqualTo(nightMageSkin().getName()); + } + + @DisplayName("기본 새 찾는 쿼리") + @Nested + class FindDefaultBird { + + @DisplayName("default 가져오기 성공") + @Test + void bird_find_success() { + // given + Member member = MemberFixture.member("fffdd"); + member.exitRoom(RoomType.MORNING); + memberRepository.save(member); + + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + Item killer = ItemFixture.morningKillerSkin().build(); + itemRepository.saveAll(List.of(night, morning, killer)); + + Inventory nightInven = InventoryFixture.inventory(member.getId(), night); + nightInven.select(member); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(member); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // when + List inventories = inventorySearchRepository.findDefaultSkin(member.getId()); + + // then + assertThat(inventories).hasSize(2); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java new file mode 100644 index 00000000..1d1988a7 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java @@ -0,0 +1,100 @@ +package com.moabam.api.domain.item.repository; + +import static com.moabam.support.fixture.InventoryFixture.*; +import static com.moabam.support.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.support.annotation.QuerydslRepositoryTest; + +@QuerydslRepositoryTest +class ItemSearchRepositoryTest { + + @Autowired + ItemSearchRepository itemSearchRepository; + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @DisplayName("구매하지 않은 아이템 목록을 조회한다.") + @Nested + class FindNotPurchasedItems { + + @DisplayName("해당 타입의 구매하지 않은 아이템 목록을 조회한다.") + @Test + void success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, morningSantaSkin)); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + itemRepository.save(nightMageSkin()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(1) + .containsExactly(morningKillerSkin); + } + + @DisplayName("구매하지 않은 아이템 목록은 레벨 순으로 정렬된다.") + @Test + void sorted_by_level_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().unlockLevel(5).build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().unlockLevel(1).build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningKillerSkin, morningSantaSkin); + } + + @DisplayName("레벨이 같으면 가격 순으로 정렬된다.") + @Test + void sorted_by_price_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().bugPrice(10).build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().bugPrice(20).build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningSantaSkin, morningKillerSkin); + } + + @DisplayName("레벨과 가격이 같으면 이름 순으로 정렬된다.") + @Test + void sorted_by_name_success() { + // given + Long memberId = 1L; + Item morningSantaSkin = itemRepository.save(morningSantaSkin().build()); + Item morningKillerSkin = itemRepository.save(morningKillerSkin().build()); + + // when + List actual = itemSearchRepository.findNotPurchasedItems(memberId, ItemType.MORNING); + + // then + assertThat(actual).hasSize(2) + .containsExactly(morningSantaSkin, morningKillerSkin); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/member/BadgeRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/BadgeRepositoryTest.java new file mode 100644 index 00000000..a250f84b --- /dev/null +++ b/src/test/java/com/moabam/api/domain/member/BadgeRepositoryTest.java @@ -0,0 +1,84 @@ +package com.moabam.api.domain.member; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.application.member.BadgeService; +import com.moabam.api.domain.member.repository.BadgeRepository; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.MemberFixture; + +@QuerydslRepositoryTest +class BadgeRepositoryTest { + + @Autowired + BadgeRepository badgeRepository; + + @Autowired + MemberRepository memberRepository; + + @DisplayName("인증 횟수에 따른 값 뱃지 확인") + @Test + void get_badge_by_certifyCount() { + assertThat(BadgeType.getBadgeFrom(10).get()).isEqualTo(BadgeType.BIRTH); + assertThat(BadgeType.getBadgeFrom(100).get()).isEqualTo(BadgeType.LEVEL10); + assertThat(BadgeType.getBadgeFrom(500).get()).isEqualTo(BadgeType.LEVEL50); + assertThat(BadgeType.getBadgeFrom(9)).isEmpty(); + } + + @DisplayName("뱃지 생성 성공") + @ParameterizedTest + @ValueSource(ints = {10, 100, 500}) + void member_get_badge_success(int certifyCount) { + // given + BadgeService badgeService = new BadgeService(badgeRepository); + + Member member = MemberFixture.member(); + for (int i = 0; i < certifyCount; i++) { + member.increaseTotalCertifyCount(); + } + + memberRepository.save(member); + + // when + badgeService.createBadge(member.getId(), member.getTotalCertifyCount()); + BadgeType expectedType = BadgeType.getBadgeFrom(certifyCount).get(); + + // then + assertThat(badgeRepository.existsByMemberIdAndType(member.getId(), expectedType)) + .isTrue(); + } + + @DisplayName("뱃지가 있으면 저장하지 않는다.") + @ParameterizedTest + @ValueSource(ints = {10, 100, 500}) + void already_exist_bage_then_no_save(int certifyCount) { + // given + BadgeService badgeService = new BadgeService(badgeRepository); + + Member member = MemberFixture.member(); + for (int i = 0; i < certifyCount; i++) { + member.increaseTotalCertifyCount(); + } + + memberRepository.save(member); + + // when + BadgeType expectedType = BadgeType.getBadgeFrom(certifyCount).get(); + + Badge badge = Badge.builder().memberId(member.getId()).type(expectedType).build(); + badgeRepository.save(badge); + + // then + assertThatNoException() + .isThrownBy(() -> badgeService.createBadge(member.getId(), member.getTotalCertifyCount())); + assertThat(badgeRepository.existsByMemberIdAndType(member.getId(), expectedType)) + .isTrue(); + } +} diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java new file mode 100644 index 00000000..dd7f0ce4 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -0,0 +1,152 @@ +package com.moabam.api.domain.member; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.application.member.MemberMapper; +import com.moabam.api.domain.member.repository.BadgeRepository; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.member.repository.MemberSearchRepository; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.dto.member.MemberInfo; +import com.moabam.api.dto.member.MemberInfoSearchResponse; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.BadgeFixture; +import com.moabam.support.fixture.MemberFixture; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@QuerydslRepositoryTest +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @Autowired + MemberSearchRepository memberSearchRepository; + + @Autowired + RoomRepository roomRepository; + + @Autowired + BadgeRepository badgeRepository; + + @Autowired + ParticipantRepository participantRepository; + + @PersistenceContext + EntityManager entityManager; + + @DisplayName("회원 생성 테스트") + @Test + void test() { + // given + Member member = MemberFixture.member("313"); + memberRepository.save(member); + + // when + Member savedMember = memberRepository.findBySocialId(member.getSocialId()).orElse(null); + + // then + assertThat(savedMember).isNotNull(); + } + + @DisplayName("회원 정보 찾는 Query") + @Nested + class FindMemberInfo { + + @DisplayName("회원 없어서 실패") + @Test + void member_not_found() { + // Given + List memberInfos = memberSearchRepository.findMemberAndBadges(999L, false); + + // When + Then + assertThat(memberInfos).isEmpty(); + } + + @DisplayName("성공") + @Test + void search_info_success() { + // given + Member member = MemberFixture.member("hhhh"); + member.enterRoom(RoomType.MORNING); + memberRepository.save(member); + + Badge birth = BadgeFixture.badge(member.getId(), BadgeType.BIRTH); + Badge level50 = BadgeFixture.badge(member.getId(), BadgeType.LEVEL50); + Badge level10 = BadgeFixture.badge(member.getId(), BadgeType.LEVEL10); + List badges = List.of(birth, level10, level50); + badgeRepository.saveAll(badges); + + // when + List memberInfos = memberSearchRepository.findMemberAndBadges(member.getId(), true); + + // then + assertThat(memberInfos).isNotEmpty(); + + MemberInfoSearchResponse memberInfoSearchResponse = MemberMapper.toMemberInfoSearchResponse(memberInfos); + assertThat(memberInfoSearchResponse.badges()).hasSize(badges.size()); + } + + @DisplayName("성공") + @Test + void no_badges_search_success() { + // given + Member member = MemberFixture.member("ttttt"); + member.enterRoom(RoomType.MORNING); + memberRepository.save(member); + + // when + List memberInfos = memberSearchRepository.findMemberAndBadges(member.getId(), true); + + // then + assertThat(memberInfos).isNotEmpty(); + + MemberInfoSearchResponse memberInfoSearchResponse = MemberMapper.toMemberInfoSearchResponse(memberInfos); + assertThat(memberInfoSearchResponse.badges()).isEmpty(); + } + } + + @DisplayName("삭제된 회원 찾기 테스트") + @Transactional + @Test + void findMemberTest() { + // Given + Member member = MemberFixture.member(); + + // When + memberRepository.save(member); + + member.delete(LocalDateTime.now()); + memberRepository.flush(); + memberRepository.delete(member); + + memberRepository.flush(); + + // then + Optional deletedMember = memberSearchRepository.findMember(member.getId(), false); + + Assertions.assertAll( + () -> assertThat(deletedMember).isPresent(), + () -> { + Member delete = deletedMember.get(); + assertThat(delete.getSocialId()).contains("delete"); + assertThat(delete.getDeletedAt()).isNotNull(); + } + ); + } +} diff --git a/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java new file mode 100644 index 00000000..681a9aa5 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java @@ -0,0 +1,93 @@ +package com.moabam.api.domain.notification.repository; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; + +@ExtendWith(MockitoExtension.class) +class NotificationRepositoryTest { + + @InjectMocks + NotificationRepository notificationRepository; + + @Mock + ValueRedisRepository valueRedisRepository; + + @DisplayName("콕 알림이 성공적으로 저장된다. - Void") + @Test + void saveKnock_success() { + // When + notificationRepository.saveKnock(1L, 1L, 1L); + + // Then + verify(valueRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("콕 찌르는 사용자의 ID가 Null인 콕 알림을 저장한다. - NullPointerException") + @Test + void saveKnock_MemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.saveKnock(null, 1L, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("콕 찌를 대상의 ID가 Null인 콕 알림을 저장한다. - NullPointerException") + @Test + void saveKnock_TargetId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.saveKnock(1L, null, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("방 ID가 Null인 콕 알림을 저장한다. - NullPointerException") + @Test + void saveKnock_RoomId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.saveKnock(1L, 2L, null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("콕 알림 여부 체크를 성공적으로 확인한다. - Boolean") + @Test + void existsKnockByKey_success() { + // When + notificationRepository.existsKnockByKey(1L, 1L, 1L); + + // Then + verify(valueRedisRepository).hasKey(any(String.class)); + } + + @DisplayName("콕 찌르는 사용자의 ID가 Null인 콕 알림 여부를 체크한다. - NullPointerException") + @Test + void existsKnockByKey_MemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.existsKnockByKey(null, 1L, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("콕 찌를 상대 ID가 Null인 콕 알림 여부를 체크한다. - NullPointerException") + @Test + void existsKnockByKey_TargetId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.existsKnockByKey(1L, null, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("방 ID가 Null인 콕 알림 여부를 체크한다. - NullPointerException") + @Test + void existsKnockByKey_RoomId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.existsKnockByKey(1L, 2L, null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/domain/payment/OrderTest.java b/src/test/java/com/moabam/api/domain/payment/OrderTest.java new file mode 100644 index 00000000..c84b2dfc --- /dev/null +++ b/src/test/java/com/moabam/api/domain/payment/OrderTest.java @@ -0,0 +1,24 @@ +package com.moabam.api.domain.payment; + +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OrderTest { + + @DisplayName("주문 id를 갱신한다.") + @Test + void update_id_success() { + // given + Order order = order(bugProduct()); + + // when + order.updateId(ORDER_ID); + + // then + assertThat(order.getId()).isEqualTo(ORDER_ID); + } +} diff --git a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java new file mode 100644 index 00000000..ede0ed7a --- /dev/null +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -0,0 +1,93 @@ +package com.moabam.api.domain.payment; + +import static com.moabam.support.fixture.CouponFixture.*; +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.global.error.exception.BadRequestException; + +class PaymentTest { + + @DisplayName("금액이 음수이면 예외가 발생한다.") + @Test + void validate_amount_exception() { + Payment.PaymentBuilder paymentBuilder = Payment.builder() + .memberId(1L) + .product(bugProduct()) + .order(order(bugProduct())) + .totalAmount(-1000); + + assertThatThrownBy(paymentBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("결제 금액은 0 이상이어야 합니다."); + } + + @DisplayName("쿠폰을 적용한다.") + @Nested + class ApplyCoupon { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Payment payment = payment(bugProduct()); + Coupon coupon = discount1000Coupon(); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + // when + payment.applyCoupon(couponWallet); + + // then + assertThat(payment.getTotalAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); + assertThat(payment.getDiscountAmount()).isEqualTo(coupon.getPoint()); + } + + @DisplayName("할인 금액이 더 크면 0으로 처리한다.") + @Test + void discount_amount_greater() { + // given + Payment payment = payment(bugProduct()); + Coupon coupon = discount10000Coupon(); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + // when + payment.applyCoupon(couponWallet); + + // then + assertThat(payment.getTotalAmount()).isZero(); + } + } + + @DisplayName("해당 회원의 결제 정보가 아니면 예외가 발생한다.") + @Test + void validate_by_member_exception() { + // given + Long memberId = 2L; + Payment payment = payment(bugProduct()); + + // when, then + assertThatThrownBy(() -> payment.validateByMember(memberId)) + .isInstanceOf(BadRequestException.class) + .hasMessage("해당 회원의 결제 정보가 아닙니다."); + } + + @DisplayName("결제를 요청한다.") + @Test + void request_success() { + // given + Payment payment = payment(bugProduct()); + + // when + payment.request(ORDER_ID); + + // then + assertThat(payment.getOrder().getId()).isEqualTo(ORDER_ID); + } +} diff --git a/src/test/java/com/moabam/api/domain/product/ProductTest.java b/src/test/java/com/moabam/api/domain/product/ProductTest.java new file mode 100644 index 00000000..4aa61a90 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/product/ProductTest.java @@ -0,0 +1,36 @@ +package com.moabam.api.domain.product; + +import static org.assertj.core.api.AssertionsForClassTypes.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.error.exception.BadRequestException; + +class ProductTest { + + @DisplayName("상품 가격이 0 보다 작으면 예외가 발생한다.") + @Test + void validate_price_exception() { + Product.ProductBuilder productBuilder = Product.builder() + .name("황금벌레 10") + .price(-10); + + assertThatThrownBy(productBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("가격은 0 이상이어야 합니다."); + } + + @DisplayName("상품량이 1 보다 작으면 예외가 발생한다.") + @Test + void validate_quantity_exception() { + Product.ProductBuilder productBuilder = Product.builder() + .name("황금벌레 10") + .price(1000) + .quantity(-1); + + assertThatThrownBy(productBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("수량은 1 이상이어야 합니다."); + } +} diff --git a/src/test/java/com/moabam/api/domain/room/CertificationTest.java b/src/test/java/com/moabam/api/domain/room/CertificationTest.java new file mode 100644 index 00000000..338bbc23 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/room/CertificationTest.java @@ -0,0 +1,39 @@ +package com.moabam.api.domain.room; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class CertificationTest { + + String content = "물 마시기"; + String image = "https://s3.testtest"; + + @DisplayName("Certification 생성 성공") + @Test + void create_certification_success() { + Room room = Room.builder() + .title("앵윤이의 방") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(9) + .build(); + + Routine routine = Routine.builder() + .room(room) + .content(content) + .build(); + + assertThatNoException().isThrownBy(() -> { + Certification certification = Certification.builder() + .routine(routine) + .memberId(1L) + .image(image).build(); + + assertThat(certification.getImage()).isEqualTo(image); + assertThat(certification.getMemberId()).isEqualTo(1L); + assertThat(certification.getRoutine()).isEqualTo(routine); + }); + } +} diff --git a/src/test/java/com/moabam/api/domain/room/RoomTest.java b/src/test/java/com/moabam/api/domain/room/RoomTest.java new file mode 100644 index 00000000..72e350df --- /dev/null +++ b/src/test/java/com/moabam/api/domain/room/RoomTest.java @@ -0,0 +1,111 @@ +package com.moabam.api.domain.room; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.RoomFixture; + +class RoomTest { + + @DisplayName("비밀번호 없이 방 생성 성공") + @Test + void create_room_without_password_success() { + // given, when + Room room = Room.builder() + .title("앵윤이의 방") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(9) + .build(); + + // then + assertThat(room.getPassword()).isNull(); + assertThat(room.getRoomImage()).isEqualTo("https://image.moabam.com/moabam/default/room-level-00.png"); + assertThat(room.getRoomType()).isEqualTo(RoomType.MORNING); + assertThat(room.getCertifyTime()).isEqualTo(10); + assertThat(room.getMaxUserCount()).isEqualTo(9); + assertThat(room.getLevel()).isZero(); + assertThat(room.getCurrentUserCount()).isEqualTo(1); + assertThat(room.getAnnouncement()).isNull(); + } + + @DisplayName("비밀번호 설정 후 방 생성 성공") + @Test + void create_room_with_password_success() { + // given, when + Room room = Room.builder() + .title("앵윤이의 방") + .password("12345") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(9) + .build(); + + // then + assertThat(room.getPassword()).isEqualTo("12345"); + } + + @DisplayName("아침 방 설정 시, 저녁 시간이 들어오는 예외 발생") + @ParameterizedTest + @CsvSource({ + "13", "19", "3", "11", "0" + }) + void morning_time_validate_exception(int certifyTime) { + Room room = Room.builder() + .title("모아밤 짱") + .password("1234") + .roomType(RoomType.MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + // given, when, then + assertThatThrownBy(() -> room.changeCertifyTime(certifyTime)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_REQUEST_FIELD.getMessage()); + } + + @DisplayName("저녁 방 설정 시, 아침 시간이 들어오는 경우 예외 발생") + @ParameterizedTest + @CsvSource({ + "3", "5", "-1", "15", "8", "19" + }) + void night_time_validate_exception(int certifyTime) { + Room room = Room.builder() + .title("모아밤 짱") + .roomType(RoomType.NIGHT) + .certifyTime(21) + .maxUserCount(5) + .build(); + + // given, when, then + assertThatThrownBy(() -> room.changeCertifyTime(certifyTime)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_REQUEST_FIELD.getMessage()); + } + + @DisplayName("레벨에 따른 이미지 업데이트") + @ParameterizedTest + @CsvSource({ + "1, https://image.moabam.com/moabam/default/room-level-01.png", + "2, https://image.moabam.com/moabam/default/room-level-02.png", + "3, https://image.moabam.com/moabam/default/room-level-03.png", + "4, https://image.moabam.com/moabam/default/room-level-04.png", + }) + void update_room_image_success(int level, String image) { + // given + Room room = RoomFixture.room(); + + // when + room.upgradeRoomImage(level); + + // then + assertThat(room.getRoomImage()).isEqualTo(image); + } +} diff --git a/src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java new file mode 100644 index 00000000..27ca807a --- /dev/null +++ b/src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java @@ -0,0 +1,109 @@ +package com.moabam.api.domain.room.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.RoomFixture; + +@QuerydslRepositoryTest +class CertificationsSearchRepositoryTest { + + @Autowired + private CertificationsSearchRepository certificationsSearchRepository; + + @Autowired + private CertificationRepository certificationRepository; + + @Autowired + private DailyMemberCertificationRepository dailyMemberCertificationRepository; + + @Autowired + private DailyRoomCertificationRepository dailyRoomCertificationRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoutineRepository routineRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @DisplayName("방에서 당일 유저들의 인증 조회") + @Test + void find_certifications_test() { + // given + Room room = RoomFixture.room(); + List routines = RoomFixture.routines(room); + Certification certification1 = RoomFixture.certification(routines.get(0)); + Certification certification2 = RoomFixture.certification(routines.get(1)); + + Room savedRoom = roomRepository.save(room); + routineRepository.save(routines.get(0)); + routineRepository.save(routines.get(1)); + certificationRepository.save(certification1); + certificationRepository.save(certification2); + + // when + List actual = certificationsSearchRepository.findCertifications(savedRoom.getId(), + LocalDate.now()); + + //then + assertThat(actual).hasSize(2) + .containsExactly(certification1, certification2); + } + + @DisplayName("당일 유저가 특정 방에서 인증 여부 조회") + @Test + void find_daily_member_certification() { + // given + Room room = roomRepository.save(RoomFixture.room()); + Participant participant = participantRepository.save(RoomFixture.participant(room, 1L)); + + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(1L, + room.getId(), participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + // when + Optional actual = certificationsSearchRepository.findDailyMemberCertification(1L, + room.getId(), LocalDate.now()); + + // then + assertThat(actual) + .isPresent() + .contains(dailyMemberCertification); + } + + @DisplayName("당일 방의 인증 여부 조회") + @Test + void find_daily_room_certification() { + // given + Room room = roomRepository.save(RoomFixture.room()); + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(room.getId(), + LocalDate.now()); + dailyRoomCertificationRepository.save(dailyRoomCertification); + + // when + Optional actual = certificationsSearchRepository.findDailyRoomCertification( + room.getId(), LocalDate.now()); + + // then + assertThat(actual) + .isPresent() + .contains(dailyRoomCertification); + } +} diff --git a/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java new file mode 100644 index 00000000..82937bf5 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java @@ -0,0 +1,48 @@ +package com.moabam.api.domain.room.repository; + +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.global.config.JpaConfig; + +@DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import({JpaConfig.class, ParticipantSearchRepository.class}) +class ParticipantSearchRepositoryTest { + + @Autowired + private ParticipantSearchRepository participantSearchRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private RoomRepository roomRepository; + + @DisplayName("인증 시간에 따른 참여자 조회를 성공적으로 했을 때, - List") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideRoomAndParticipants") + @ParameterizedTest + void participantSearchRepository_findAllByRoomCertifyTime(Room room, List participants) { + // Given + roomRepository.save(room); + participantRepository.saveAll(participants); + + // When + List actual = participantSearchRepository.findAllByRoomCertifyTime(10); + + // Then + assertThat(actual).hasSize(5); + } +} diff --git a/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java new file mode 100644 index 00000000..3454611b --- /dev/null +++ b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java @@ -0,0 +1,31 @@ +package com.moabam.api.dto.coupon; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDate; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +class CreateCouponRequestTest { + + @DisplayName("쿠폰 발급 가능 시작 날짜가 올바른 형식으로 입력된다. - yyyy-MM-dd") + @Test + void startAt_success() throws JsonProcessingException { + // Given + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + String json = "{\"startAt\":\"2023-11-09\"}"; + + // When + CreateCouponRequest actual = objectMapper.readValue(json, CreateCouponRequest.class); + + // Then + assertThat(actual.startAt()).isEqualTo(LocalDate.of(2023, 11, 9)); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java new file mode 100644 index 00000000..6eed5267 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java @@ -0,0 +1,88 @@ +package com.moabam.api.infrastructure.fcm; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.time.Duration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.infrastructure.redis.ValueRedisRepository; + +@ExtendWith(MockitoExtension.class) +class FcmRepositoryTest { + + @InjectMocks + FcmRepository fcmRepository; + + @Mock + ValueRedisRepository valueRedisRepository; + + @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") + @Test + void saveToken_success() { + // When + fcmRepository.saveToken("FCM-TOKEN", 1L); + + // Then + verify(valueRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 저장한다. - NullPointerException") + @Test + void saveToken_MemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.saveToken("FCM-TOKEN", null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("토큰이 Null인 FCM 토큰을 저장한다. - NullPointerException") + @Test + void saveToken_FcmToken_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.saveToken(null, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") + @Test + void deleteTokenByMemberId_success() { + // When + fcmRepository.deleteTokenByMemberId(1L); + + // Then + verify(valueRedisRepository).delete(any(String.class)); + } + + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 삭제한다. - NullPointerException") + @Test + void deleteTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.deleteTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") + @Test + void findTokenByMemberId_success() { + // When + fcmRepository.findTokenByMemberId(1L); + + // Then + verify(valueRedisRepository).get(any(String.class)); + } + + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 조회한다. - NullPointerException") + @Test + void findTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.findTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java new file mode 100644 index 00000000..9720b843 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -0,0 +1,97 @@ +package com.moabam.api.infrastructure.fcm; + +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.moabam.global.config.FcmConfig; +import com.moabam.support.common.WithoutFilterSupporter; + +@SpringBootTest(classes = {FcmConfig.class, FcmService.class}) +class FcmServiceTest extends WithoutFilterSupporter { + + @Autowired + FcmService fcmService; + + @MockBean + FirebaseMessaging firebaseMessaging; + + @MockBean + FcmRepository fcmRepository; + + @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") + @Test + void saveToken_success() { + // When + fcmService.createToken("FCM-TOKEN", 1L); + + // Then + verify(fcmRepository).saveToken(any(String.class), any(Long.class)); + } + + @DisplayName("FCM 토큰으로 빈값이 넘어와 아무일도 일어나지 않는다. - Void") + @Test + void saveToken_Blank() { + // When + fcmService.createToken("", 1L); + + // Then + verify(fcmRepository, times(0)).saveToken(any(String.class), any(Long.class)); + } + + @DisplayName("FCM 토큰으로 null이 넘어와 아무일도 일어나지 않는다. - Void") + @Test + void saveToken_Null() { + // When + fcmService.createToken(null, 1L); + + // Then + verify(fcmRepository, times(0)).saveToken(any(String.class), any(Long.class)); + } + + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") + @Test + void deleteTokenByMemberId_success() { + // When + fcmRepository.deleteTokenByMemberId(1L); + + // Then + verify(fcmRepository).deleteTokenByMemberId(any(Long.class)); + } + + @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") + @Test + void findTokenByMemberId_success() { + // When + fcmRepository.findTokenByMemberId(1L); + + // Then + verify(fcmRepository).findTokenByMemberId(any(Long.class)); + } + + @DisplayName("비동기 FCM 알림을 성공적으로 보낸다. - Void") + @Test + void sendAsync_success() { + // When + fcmService.sendAsync("FCM-TOKEN", "title", "body"); + + // Then + verify(firebaseMessaging).sendAsync(any(Message.class)); + } + + @DisplayName("FCM 토큰이 null이여서 비동기 FCM 알림을 보내지 않는다. - Void") + @Test + void sendAsync_null() { + // When + fcmService.sendAsync(null, "titile", "body"); + + // Then + verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java new file mode 100644 index 00000000..10617019 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java @@ -0,0 +1,92 @@ +package com.moabam.api.infrastructure.payment; + +import static com.moabam.support.fixture.PaymentFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.global.config.TossPaymentConfig; +import com.moabam.global.error.exception.MoabamException; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +@SpringBootTest +@ActiveProfiles("test") +class TossPaymentServiceTest { + + @Autowired + TossPaymentConfig config; + + @Autowired + ObjectMapper objectMapper; + + TossPaymentService tossPaymentService; + MockWebServer mockWebServer; + + @BeforeEach + public void setup() { + mockWebServer = new MockWebServer(); + tossPaymentService = new TossPaymentService( + new TossPaymentConfig(mockWebServer.url("/").toString(), config.secretKey()) + ); + tossPaymentService.init(); + } + + @AfterEach + public void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @DisplayName("결제 승인을 요청한다.") + @Nested + class Confirm { + + @DisplayName("성공한다.") + @Test + void success() throws Exception { + // given + ConfirmPaymentRequest request = confirmPaymentRequest(); + ConfirmTossPaymentResponse expected = confirmTossPaymentResponse(); + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(objectMapper.writeValueAsString(expected)) + .addHeader("Content-Type", "application/json")); + + // when + ConfirmTossPaymentResponse actual = tossPaymentService.confirm(request); + + // then + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("예외가 발생한다.") + @Test + void exception() { + // given + ConfirmPaymentRequest request = confirmPaymentRequest(); + String jsonString = "{\"code\":\"NOT_FOUND_PAYMENT\",\"message\":\"존재하지 않는 결제 입니다.\"}"; + mockWebServer.enqueue(new MockResponse() + .setResponseCode(404) + .setBody(jsonString) + .addHeader("Content-Type", "application/json")); + + // when, then + assertThatThrownBy(() -> tossPaymentService.confirm(request)) + .isInstanceOf(MoabamException.class) + .hasMessage("존재하지 않는 결제 입니다."); + } + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java new file mode 100644 index 00000000..ab0cd833 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java @@ -0,0 +1,77 @@ +package com.moabam.api.infrastructure.redis; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.global.config.EmbeddedRedisConfig; +import com.moabam.global.config.RedisConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.TokenSaveValueFixture; + +@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, HashRedisRepository.class}) +class HashRedisRepositoryTest { + + @Autowired + private HashRedisRepository hashRedisRepository; + + String key = "auth_123"; + String token = "token"; + String ip = "ip"; + TokenSaveValue tokenSaveValue = TokenSaveValueFixture.tokenSaveValue(token, ip); + Duration duration = Duration.ofMillis(5000); + + @BeforeEach + void setUp() { + hashRedisRepository.save(key, (Object)tokenSaveValue, duration); + } + + @AfterEach + void delete() { + hashRedisRepository.delete(key); + } + + @DisplayName("레디스에 hash 저장 성공") + @Test + void hashTemplate_repository_save_success() { + // Given + When + TokenSaveValue object = (TokenSaveValue)hashRedisRepository.get(key); + + // Then + assertAll( + () -> assertThat(object.refreshToken()).isEqualTo(token), + () -> assertThat(object.loginIp()).isEqualTo(ip) + ); + } + + @DisplayName("삭제 성공 테스트") + @Test + void delete_and_get_null() { + // Given + hashRedisRepository.delete(key); + + // When + Then + assertThatThrownBy(() -> hashRedisRepository.get(key)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + } + + @DisplayName("토큰이 null 이어서 예외 발생") + @Test + void valid_token_failby_token_is_null() { + // Given + When + Then + assertThatThrownBy(() -> hashRedisRepository.get("0")) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java b/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java new file mode 100644 index 00000000..006d392e --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java @@ -0,0 +1,66 @@ +package com.moabam.api.infrastructure.redis; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.Duration; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.auth.repository.TokenRepository; +import com.moabam.api.domain.member.Role; +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.support.fixture.TokenSaveValueFixture; + +@ExtendWith(MockitoExtension.class) +class TokenRepostiroyTest { + + @InjectMocks + TokenRepository tokenRepository; + + @Mock + HashRedisRepository hashRedisRepository; + + @DisplayName("토큰 저장 성공") + @Test + void save_token_suceess() { + // Given + willDoNothing().given(hashRedisRepository).save(any(), any(TokenSaveValue.class), any(Duration.class)); + + // When + Then + Assertions.assertThatNoException() + .isThrownBy(() -> tokenRepository.saveToken(1L, TokenSaveValueFixture.tokenSaveValue(), Role.USER)); + } + + @DisplayName("토큰 조회 성공") + @Test + void token_get_success() { + // given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(hashRedisRepository).get(anyString()); + + // when + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L, Role.USER); + + // then + assertAll( + () -> assertThat(tokenSaveValue).isNotNull(), + () -> assertThat(tokenSaveValue.refreshToken()).isEqualTo("token") + ); + } + + @DisplayName("토큰 저장 삭제") + @Test + void delete_token_suceess() { + // When + Then + Assertions.assertThatNoException() + .isThrownBy(() -> tokenRepository.delete(1L, Role.USER)); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java new file mode 100644 index 00000000..7be74a87 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java @@ -0,0 +1,86 @@ +package com.moabam.api.infrastructure.redis; + +import static org.assertj.core.api.Assertions.*; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.moabam.global.config.EmbeddedRedisConfig; + +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ValueRedisRepository.class}) +class ValueRedisRepositoryTest { + + @Autowired + ValueRedisRepository valueRedisRepository; + + String key = "key"; + String value = "value"; + String stockKey = "key_INCR"; + Duration duration = Duration.ofMillis(5000); + + @BeforeEach + void setUp() { + valueRedisRepository.save(key, value, duration); + } + + @AfterEach + void setDown() { + if (valueRedisRepository.hasKey(key)) { + valueRedisRepository.delete(key); + } + + if (valueRedisRepository.hasKey(stockKey)) { + valueRedisRepository.delete(stockKey); + } + } + + @DisplayName("레디스에 문자열 데이터가 성공적으로 저장된다. - Void") + @Test + void save_success() { + // Then + assertThat(valueRedisRepository.get(key)).isEqualTo(value); + } + + @DisplayName("레디스의 특정 데이터가 성공적으로 조회된다. - String(Value)") + @Test + void get_success() { + // When + String actual = valueRedisRepository.get(key); + + // Then + assertThat(actual).isEqualTo(valueRedisRepository.get(key)); + } + + @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") + @Test + void hasKey_success() { + // When & Then + assertThat(valueRedisRepository.hasKey("not found key")).isFalse(); + } + + @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") + @Test + void delete_success() { + // When + valueRedisRepository.delete(key); + + // Then + assertThat(valueRedisRepository.hasKey(key)).isFalse(); + } + + @DisplayName("레디스의 특정 데이터의 값이 1 증가한다.") + @Test + void increment_success() { + // When + Long actual = valueRedisRepository.increment(stockKey, 1); + + // Then + assertThat(actual).isEqualTo(1L); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java new file mode 100644 index 00000000..072dcf25 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java @@ -0,0 +1,111 @@ +package com.moabam.api.infrastructure.redis; + +import static org.assertj.core.api.Assertions.*; + +import java.util.Set; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import com.moabam.global.config.EmbeddedRedisConfig; + +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class, ValueRedisRepository.class}) +class ZSetRedisRepositoryTest { + + @Autowired + ZSetRedisRepository zSetRedisRepository; + + @Autowired + ValueRedisRepository valueRedisRepository; + + @Autowired + RedisTemplate redisTemplate; + + String key = "key"; + Long value = 1L; + int expireDays = 2; + + @AfterEach + void afterEach() { + if (valueRedisRepository.hasKey(key)) { + valueRedisRepository.delete(key); + } + } + + @Disabled + @DisplayName("레디스의 SortedSet 데이터가 성공적으로 저장된다. - Void") + @Test + void addIfAbsent_success() { + // When + zSetRedisRepository.addIfAbsent(key, value, 1, expireDays); + + // Then + assertThat(valueRedisRepository.hasKey(key)).isTrue(); + } + + @Disabled + @DisplayName("이미 존재하는 값을 한 번 더 저장을 시도한다. - Void") + @Test + void setRedisRepository_addIfAbsent_not_update() { + // When + zSetRedisRepository.addIfAbsent(key, value, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value, 5, expireDays); + + // Then + assertThat(redisTemplate.opsForZSet().score(key, value)).isEqualTo(1); + } + + @Disabled + @DisplayName("저장된 데이터와 동일한 갯수만큼 조회한다. - Set") + @Test + void range_same_success() { + // Given + zSetRedisRepository.addIfAbsent(key, value + 1, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 2, 2, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 3, 3, expireDays); + + // When + Set actual = zSetRedisRepository.range(key, 0, 3); + + // Then + assertThat(actual).hasSize(3); + } + + @Disabled + @DisplayName("저장된 데이터보다 많은 갯수만큼 조회한다. - Set") + @Test + void range_more_success() { + // Given + zSetRedisRepository.addIfAbsent(key, value + 1, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 2, 2, expireDays); + + // When + Set actual = zSetRedisRepository.range(key, 0, 3); + + // Then + assertThat(actual).hasSize(2); + } + + @Disabled + @DisplayName("저장된 데이터보다 더 적은 갯수만큼 조회한다. - Set") + @Test + void range_less_success() { + // Given + zSetRedisRepository.addIfAbsent(key, value + 1, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 2, 2, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 3, 3, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 4, 4, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 5, 5, expireDays); + + // When + Set actual = zSetRedisRepository.range(key, 0, 3); + + // Then + assertThat(actual).hasSize(3); + } +} diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java new file mode 100644 index 00000000..c7887beb --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -0,0 +1,190 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; +import static com.moabam.support.fixture.BugFixture.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static java.nio.charset.StandardCharsets.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.bug.BugMapper; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.product.ProductMapper; +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.bug.BugHistoryResponse; +import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; +import com.moabam.api.dto.product.PurchaseProductRequest; +import com.moabam.api.dto.product.PurchaseProductResponse; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +class BugControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + MemberService memberService; + + @Autowired + BugHistoryRepository bugHistoryRepository; + + @Autowired + BugHistorySearchRepository bugHistorySearchRepository; + + @Autowired + ProductRepository productRepository; + + @Autowired + PaymentRepository paymentRepository; + + @DisplayName("벌레를 조회한다.") + @WithMember + @Test + void get_bug_success() throws Exception { + // given + Long memberId = getAuthMember().id(); + BugResponse expected = BugMapper.toBugResponse(bug()); + given(memberService.findMember(memberId)).willReturn(member()); + + // expected + String content = mockMvc.perform(get("/bugs") + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + BugResponse actual = objectMapper.readValue(content, BugResponse.class); + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("벌레 내역을 조회한다.") + @Nested + class GetBugHistory { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthMember().id(); + bugHistoryRepository.save(rewardMorningBugHistory(memberId)); + + // expected + String content = mockMvc.perform(get("/bugs/history") + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + BugHistoryResponse actual = objectMapper.readValue(content, BugHistoryResponse.class); + assertThat(actual.history().get(0).bugType()).isEqualTo(BugType.MORNING); + assertThat(actual.history().get(0).actionType()).isEqualTo(BugActionType.REWARD); + assertThat(actual.history().get(0).quantity()).isEqualTo(REWARD_MORNING_BUG); + assertThat(actual.history().get(0).payment()).isNull(); + } + + @DisplayName("벌레 충전 내역인 경우 결제 정보를 포함한다.") + @WithMember + @Test + void charge_success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Product product = productRepository.save(bugProduct()); + Payment payment = paymentRepository.save(payment(product)); + bugHistoryRepository.save(chargeGoldenBugHistory(memberId, payment)); + + // expected + String content = mockMvc.perform(get("/bugs/history") + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + BugHistoryResponse actual = objectMapper.readValue(content, BugHistoryResponse.class); + assertThat(actual.history().get(0).bugType()).isEqualTo(BugType.GOLDEN); + assertThat(actual.history().get(0).actionType()).isEqualTo(BugActionType.CHARGE); + assertThat(actual.history().get(0).quantity()).isEqualTo(BUG_PRODUCT_QUANTITY); + assertThat(actual.history().get(0).payment().orderName()).isEqualTo(BUG_PRODUCT_NAME); + assertThat(actual.history().get(0).payment().totalAmount()).isEqualTo(BUG_PRODUCT_PRICE); + assertThat(actual.history().get(0).payment().discountAmount()).isZero(); + } + } + + @DisplayName("벌레 상품 목록을 조회한다.") + @Test + void get_bug_products_success() throws Exception { + // given + List products = productRepository.saveAll(List.of(bugProduct(), bugProduct())); + ProductsResponse expected = ProductMapper.toProductsResponse(products); + + // expected + String content = mockMvc.perform(get("/bugs/products") + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + ProductsResponse actual = objectMapper.readValue(content, ProductsResponse.class); + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("벌레 상품을 구매한다.") + @WithMember + @Test + void purchase_bug_product_success() throws Exception { + // given + Product product = productRepository.save(bugProduct()); + PurchaseProductRequest request = new PurchaseProductRequest(null); + + // expected + String content = mockMvc.perform(post("/bugs/products/{productId}/purchase", product.getId()) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + PurchaseProductResponse actual = objectMapper.readValue(content, PurchaseProductResponse.class); + assertThat(actual.orderName()).isEqualTo(BUG_PRODUCT_NAME); + assertThat(actual.price()).isEqualTo(BUG_PRODUCT_PRICE); + } +} diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java new file mode 100644 index 00000000..65f25723 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -0,0 +1,518 @@ +package com.moabam.api.presentation; + +import static org.hamcrest.Matchers.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.coupon.CouponMapper; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponWalletRepository; +import com.moabam.api.domain.member.Role; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.CouponFixture; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.snippet.CouponSnippet; +import com.moabam.support.snippet.CouponWalletSnippet; +import com.moabam.support.snippet.ErrorSnippet; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class CouponControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MemberRepository memberRepository; + + @Autowired + CouponRepository couponRepository; + + @Autowired + CouponWalletRepository couponWalletRepository; + + @MockBean + ClockHolder clockHolder; + + @MockBean + ZSetRedisRepository zSetRedisRepository; + + @MockBean + ValueRedisRepository valueRedisRepository; + + @WithMember(role = Role.ADMIN) + @DisplayName("POST - 쿠폰을 성공적으로 발행한다. - Void") + @Test + void create_Coupon_success() throws Exception { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("admins/coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.CREATE_COUPON_REQUEST)) + .andExpect(status().isCreated()); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("POST - 현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") + @Test + void create_Coupon_StartAt_BadRequestException() throws Exception { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(clockHolder.date()).willReturn(LocalDate.of(2025, 1, 1)); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("admins/coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.CREATE_COUPON_REQUEST, + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_START_AT_PERIOD.getMessage())); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("POST - 쿠폰 정보 오픈 날짜가 쿠폰 발급 시작 날짜와 같거나 이후인 쿠폰을 발행한다. - BadRequestException") + @Test + void create_Coupon_OpenAt_BadRequestException() throws Exception { + // Given + String couponType = CouponType.GOLDEN.getName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("admins/coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.CREATE_COUPON_REQUEST, + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD.getMessage())); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("POST - 쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") + @Test + void create_Coupon_Name_ConflictException() throws Exception { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + couponRepository.save(CouponMapper.toEntity(1L, request)); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("admins/coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.CREATE_COUPON_REQUEST, + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_NAME.getMessage())); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("POST - 쿠폰 발행 가능 날짜가 중복된 쿠폰을 발행한다. - ConflictException") + @Test + void create_Coupon_StartAt_ConflictException() throws Exception { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + Coupon conflictStartAtCoupon = CouponFixture.coupon("NotConflictName", 2, 1); + couponRepository.save(conflictStartAtCoupon); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("admins/coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.CREATE_COUPON_REQUEST, + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_START_AT.getMessage())); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("DELETE - 쿠폰을 성공적으로 삭제한다. - Void") + @Test + void delete_Coupon_success() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); + + // When & Then + mockMvc.perform(delete("/admins/coupons/" + coupon.getId())) + .andDo(print()) + .andDo(document("admins/coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember(role = Role.ADMIN) + @DisplayName("DELETE - 존재하지 않는 쿠폰을 삭제한다. - NotFoundException") + @Test + void delete_Coupon_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(delete("/admins/coupons/77777777777")) + .andDo(print()) + .andDo(document("admins/coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + } + + @DisplayName("GET - 특정 쿠폰을 성공적으로 조회한다. - CouponResponse") + @Test + void getById_Coupon_success() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); + + // When & Then + mockMvc.perform(get("/coupons/" + coupon.getId())) + .andDo(print()) + .andDo(document("coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.COUPON_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.id").value(coupon.getId())); + } + + @DisplayName("GET - 존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @Test + void getById_Coupon_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(get("/coupons/77777777777")) + .andDo(print()) + .andDo(document("coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + } + + @DisplayName("POST - 모든 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void getAllByStatus_Coupons_success(List coupons) throws Exception { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); + List coupon = couponRepository.saveAll(coupons); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + mockMvc.perform(post("/coupons/search") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("coupons/search", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.COUPON_STATUS_REQUEST, + CouponSnippet.COUPON_STATUS_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(coupon.size()))); + } + + @DisplayName("POST - 발급 가능한 쿠폰만 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void getAllByStatus_Coupon_success(List coupons) throws Exception { + // Given + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); + couponRepository.saveAll(coupons); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 3, 1)); + + // When & Then + mockMvc.perform(post("/coupons/search") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("coupons/search", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippet.COUPON_STATUS_REQUEST)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(1))); + } + + @WithMember + @DisplayName("GET - 나의 쿠폰함에서 특정 쿠폰을 조회한다. - List") + @Test + void getAllByWalletIdAndMemberId_success() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // When & Then + mockMvc.perform(get("/my-coupons/" + couponWallet.getId())) + .andDo(print()) + .andDo(document("my-coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponWalletSnippet.COUPON_WALLET_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value(coupon.getId())) + .andExpect(jsonPath("$[0].name").value(coupon.getName())); + } + + @WithMember + @DisplayName("GET - 나의 쿠폰 보관함에 있는 모든 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletByCouponId1_total5") + @ParameterizedTest + void getAllByWalletIdAndMemberId_all_success(List couponWallets) throws Exception { + // Given + couponWallets.forEach(couponWallet -> { + Coupon coupon = couponRepository.save(couponWallet.getCoupon()); + couponWalletRepository.save(CouponWallet.create(1L, coupon)); + }); + + // When & Then + mockMvc.perform(get("/my-coupons")) + .andDo(print()) + .andDo(document("my-coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponWalletSnippet.COUPON_WALLET_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(couponWallets.size()))); + } + + @WithMember + @DisplayName("GET - 쿠폰이 없는 사용자의 쿠폰함을 조회한다. - List") + @Test + void getAllByWalletIdAndMemberId_no_coupon() throws Exception { + // When & Then + mockMvc.perform(get("/my-coupons")) + .andDo(print()) + .andDo(document("my-coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(0))); + } + + @WithMember + @DisplayName("POST - 특정 회원이 보유한 쿠폰을 성공적으로 사용한다. - Void") + @Test + void use_success() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + memberRepository.save(MemberFixture.member(1L)); + + // When & Then + mockMvc.perform(post("/my-coupons/" + couponWallet.getId())) + .andDo(print()) + .andDo(document("my-coupons/couponWalletId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember + @DisplayName("POST - 특정 회원이 보유하지 않은 쿠폰을 사용한다. - NotFoundException") + @Test + void use_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(post("/my-coupons/" + 777L)) + .andDo(print()) + .andDo(document("my-coupons/couponWalletId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON_WALLET.getMessage())); + ; + } + + @WithMember + @DisplayName("POST - 특정 회원이 보유한 할인 쿠폰을 사용한다. - BadRequestException") + @Test + void use_BadRequestException() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon(CouponType.DISCOUNT, 1000)); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // When & Then + mockMvc.perform(post("/my-coupons/" + couponWallet.getId())) + .andDo(print()) + .andDo(document("my-coupons/couponWalletId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_DISCOUNT_COUPON.getMessage())); + } + + @WithMember + @DisplayName("POST - 쿠폰 발급을 성공적으로 한다. - Void") + @Test + void registerQueue_success() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon("CouponName", 2, 1); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); + given(zSetRedisRepository.score(anyString(), anyLong())).willReturn(null); + given(zSetRedisRepository.size(anyString())).willReturn((long)(coupon.getMaxCount() - 1)); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember + @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. - NotFoundException") + @Test + void registerQueue_NotFoundException() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon(); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + } + + @WithMember + @DisplayName("POST - 동일한 쿠폰 이벤트에 중복으로 요청한다. - ConflictException") + @Test + void registerQueue_ConflictException() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); + given(zSetRedisRepository.score(anyString(), anyLong())).willReturn(7.0); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_ISSUE.getMessage())); + } + + @WithMember + @DisplayName("POST - 선착순 이벤트가 마감된 쿠폰에 발급 요청을 한다. - BadRequestException") + @Test + void registerQueue_BadRequestException() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon(); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); + given(zSetRedisRepository.score(anyString(), anyLong())).willReturn(null); + given(zSetRedisRepository.size(anyString())).willReturn((long)(coupon.getMaxCount())); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_STOCK_END.getMessage())); + } +} diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java new file mode 100644 index 00000000..4745e997 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -0,0 +1,165 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; +import static com.moabam.support.fixture.InventoryFixture.*; +import static com.moabam.support.fixture.ItemFixture.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static java.nio.charset.StandardCharsets.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +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; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.item.ItemMapper; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.dto.item.ItemsResponse; +import com.moabam.api.dto.item.PurchaseItemRequest; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +class ItemControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + MemberService memberService; + + @MockBean + BugService bugService; + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @DisplayName("아이템 목록을 조회한다.") + @Nested + class GetItems { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Item item1 = itemRepository.save(morningSantaSkin().build()); + Inventory inventory = inventoryRepository.save(inventory(memberId, item1)); + inventory.select(member()); + Item item2 = itemRepository.save(morningKillerSkin().build()); + ItemsResponse expected = ItemMapper.toItemsResponse(item1.getId(), List.of(item1), List.of(item2)); + + // expected + String content = mockMvc.perform(get("/items") + .param("type", ItemType.MORNING.name()) + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + ItemsResponse actual = objectMapper.readValue(content, ItemsResponse.class); + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("아이템 타입이 유효하지 않으면 예외가 발생한다.") + @WithMember + @ParameterizedTest + @ValueSource(strings = {"HI", ""}) + void item_type_bad_request_exception(String itemType) throws Exception { + mockMvc.perform(get("/items") + .param("type", itemType) + .contentType(APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + } + + @Nested + @DisplayName("아이템을 구매한다.") + class PurchaseItem { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Item item = itemRepository.save(nightMageSkin()); + PurchaseItemRequest request = new PurchaseItemRequest(BugType.NIGHT); + given(memberService.findMember(memberId)).willReturn(member()); + + // expected + mockMvc.perform(post("/items/{itemId}/purchase", item.getId()) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("아이템 구매 요청 바디가 유효하지 않으면 예외가 발생한다.") + @WithMember + @Test + void bad_request_body_exception() throws Exception { + // given + Long itemId = 1L; + PurchaseItemRequest request = new PurchaseItemRequest(null); + + // expected + mockMvc.perform(post("/items/{itemId}/purchase", itemId) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 요청 정보가 아닙니다.")) + .andDo(print()); + } + } + + @DisplayName("아이템을 적용한다.") + @WithMember + @Test + void select_item_success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Item item = itemRepository.save(nightMageSkin()); + given(memberService.findMember(memberId)).willReturn(member()); + inventoryRepository.save(inventory(memberId, item)); + + // when, then + mockMvc.perform(post("/items/{itemId}/select", item.getId()) + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()); + } +} diff --git a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java new file mode 100644 index 00000000..be66cc08 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java @@ -0,0 +1,219 @@ +package com.moabam.api.presentation; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; +import com.moabam.global.auth.filter.CorsFilter; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.handler.RestTemplateResponseHandler; +import com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.ItemFixture; + +@SpringBootTest +@AutoConfigureMockMvc +class MemberAuthorizeControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + + @Autowired + ItemRepository itemRepository; + + @SpyBean + AuthorizationService authorizationService; + + @SpyBean + CorsFilter corsFilter; + + @Autowired + OAuthConfig oAuthConfig; + + static RestTemplateBuilder restTemplateBuilder; + + MockRestServiceServer mockRestServiceServer; + + @BeforeAll + static void allSetUp() { + restTemplateBuilder = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()); + } + + @BeforeEach + void setUp() { + // TODO 추후 RestTemplate -> REstTemplateBuilder & Bean등록하여 테스트 코드도 일부 변경됨 + RestTemplate restTemplate = restTemplateBuilder.build(); + ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); + mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + willReturn("http://localhost").given(corsFilter).getReferer(any()); + } + + @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") + @Test + void authorization_code_request_success() throws Exception { + // given + String uri = UriComponentsBuilder + .fromUriString(oAuthConfig.provider().authorizationUri()) + .queryParam("response_type", "code") + .queryParam("client_id", oAuthConfig.client().clientId()) + .queryParam("redirect_uri", oAuthConfig.provider().redirectUri()) + .queryParam("scope", String.join(",", oAuthConfig.client().scope())) + .toUriString(); + + // expected + ResultActions result = mockMvc.perform(get("/members/login/oauth")); + + result.andExpect(status().is3xxRedirection()) + .andExpect(MockMvcResultMatchers.header().string("Content-type", + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) + .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); + } + + @DisplayName("소셜 로그인 및 회원가입 요청 성공") + @Test + void social_login_signUp_request_success() throws Exception { + // given + MultiValueMap contentParams = new LinkedMultiValueMap<>(); + contentParams.add("grant_type", oAuthConfig.client().authorizationGrantType()); + contentParams.add("client_id", oAuthConfig.client().clientId()); + contentParams.add("redirect_uri", oAuthConfig.provider().redirectUri()); + contentParams.add("code", "test"); + contentParams.add("client_secret", oAuthConfig.client().clientSecret()); + + Item morningEgg = ItemFixture.morningSantaSkin().build(); + Item nightEgg = ItemFixture.nightMageSkin(); + itemRepository.saveAll(List.of(morningEgg, nightEgg)); + + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + String requestBody = objectMapper.writeValueAsString(authorizationCodeResponse); + AuthorizationTokenResponse authorizationTokenResponse = + AuthorizationResponseFixture.authorizationTokenResponse(); + String response = objectMapper.writeValueAsString(authorizationTokenResponse); + + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + String tokenInfoResponse = objectMapper.writeValueAsString(authorizationTokenInfoResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) + .andExpect(MockRestRequestMatchers.content().formData(contentParams)) + .andExpect(MockRestRequestMatchers.content().contentType("application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); + + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withSuccess(tokenInfoResponse, MediaType.APPLICATION_JSON)); + + mockMvc.perform(post("/members/login/kakao/oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpectAll( + status().isOk(), + MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON), + cookie().value("token_type", "Bearer"), + cookie().exists("access_token"), + cookie().httpOnly("access_token", true), + cookie().secure("access_token", true), + cookie().exists("refresh_token"), + cookie().httpOnly("refresh_token", true), + cookie().secure("refresh_token", true) + ) + .andExpect(MockMvcResultMatchers.jsonPath("$.isSignUp").value(true)); + } + + @DisplayName("Authorization Token 발급 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401, 403, 429, 500, 502, 503}) + void authorization_token_request_fail(int code) throws Exception { + // given + MultiValueMap contentParams = new LinkedMultiValueMap<>(); + contentParams.add("grant_type", oAuthConfig.client().authorizationGrantType()); + contentParams.add("client_id", oAuthConfig.client().clientId()); + contentParams.add("redirect_uri", oAuthConfig.provider().redirectUri()); + contentParams.add("code", "test"); + contentParams.add("client_secret", oAuthConfig.client().clientSecret()); + + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + String requestBody = objectMapper.writeValueAsString(authorizationCodeResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) + .andExpect(MockRestRequestMatchers.content().formData(contentParams)) + .andExpect(MockRestRequestMatchers.content().contentType("application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatusCode.valueOf(code))); + + mockMvc.perform(post("/members/login/kakao/oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("토큰 정보 요청 실패") + @ParameterizedTest + @ValueSource(ints = {400, 401}) + void token_info_response_fail(int code) throws Exception { + // given + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + + // when + doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) + .when(authorizationService).requestToken(authorizationCodeResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withStatus(HttpStatusCode.valueOf(code))); + + mockMvc.perform(post("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isBadRequest()); + } +} diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java new file mode 100644 index 00000000..80efe377 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -0,0 +1,528 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.common.util.GlobalConstant.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +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.boot.test.autoconfigure.web.client.AutoConfigureMockRestServiceServer; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.result.MockMvcResultMatchers; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.member.MemberMapper; +import com.moabam.api.domain.auth.repository.TokenRepository; +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.member.Badge; +import com.moabam.api.domain.member.BadgeType; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.api.domain.member.repository.BadgeRepository; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.member.repository.MemberSearchRepository; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.dto.auth.TokenSaveValue; +import com.moabam.api.dto.member.ModifyMemberRequest; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.global.config.EmbeddedRedisConfig; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.global.error.handler.RestTemplateResponseHandler; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.BadgeFixture; +import com.moabam.support.fixture.InventoryFixture; +import com.moabam.support.fixture.ItemFixture; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.ParticipantFixture; +import com.moabam.support.fixture.RoomFixture; +import com.moabam.support.fixture.TokenSaveValueFixture; + +import jakarta.persistence.EntityManager; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureMockRestServiceServer +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Import(EmbeddedRedisConfig.class) +class MemberControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MemberRepository memberRepository; + + @Autowired + MemberSearchRepository memberSearchRepository; + + @Autowired + TokenRepository tokenRepository; + + @Autowired + RoomRepository roomRepository; + + @Autowired + ItemRepository itemRepository; + + @Autowired + BadgeRepository badgeRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @Autowired + ParticipantRepository participantRepository; + + @Autowired + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + + @Autowired + OAuthConfig oAuthConfig; + + @SpyBean + ImageService imageService; + + RestTemplateBuilder restTemplateBuilder; + + MockRestServiceServer mockRestServiceServer; + + Member member; + + @Autowired + EntityManager entityManager; + + @Autowired + RedisTemplate redisTemplate; + + @BeforeAll + void allSetUp() { + restTemplateBuilder = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()); + + member = MemberFixture.member("1234567890987654"); + member.increaseTotalCertifyCount(); + memberRepository.save(member); + } + + @BeforeEach + void setUp() { + RestTemplate restTemplate = restTemplateBuilder.build(); + ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); + mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + member = entityManager.merge(member); + } + + @DisplayName("로그아웃 성공 테스트") + @WithMember + @Test + void logout_success() throws Exception { + // given + TokenSaveValue tokenSaveValue = TokenSaveValueFixture.tokenSaveValue(); + tokenRepository.saveToken(member.getId(), tokenSaveValue, Role.USER); + + // expected + ResultActions result = mockMvc.perform(get("/members/logout")); + + result.andExpect(status().is2xxSuccessful()); + + Assertions.assertThatThrownBy(() -> tokenRepository.getTokenSaveValue(member.getId(), Role.USER)) + .isInstanceOf(UnauthorizedException.class); + } + + @DisplayName("회원 삭제 성공 테스트") + @WithMember + @Test + void delete_member_success() throws Exception { + // Given + String nickname = member.getNickname(); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().unlink())) + .andExpect(MockRestRequestMatchers.content() + .contentType("application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(MockRestRequestMatchers.header( + "Authorization", "KakaoAK " + oAuthConfig.client().adminKey())) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatus.OK)); + + mockMvc.perform(delete("/members")); + memberRepository.flush(); + + Optional deletedMemberOptional = memberRepository.findById(member.getId()); + assertThat(deletedMemberOptional).isNotEmpty(); + + Member deletedMEmber = deletedMemberOptional.get(); + assertThat(deletedMEmber.getDeletedAt()).isNotNull(); + assertThat(deletedMEmber.getNickname()).isNull(); + } + + @DisplayName("회원이 없어서 회원 삭제 실패") + @WithMember(id = 123L) + @Test + void delete_member_failBy_not_found_member() throws Exception { + // expected + mockMvc.perform(delete("/members")) + .andExpect(status().isNotFound()); + } + + @DisplayName("연결 오류로 인한 카카오 연결 끊기 실패로 롤백") + @WithMember + @ParameterizedTest + @ValueSource(ints = {401, 400}) + void unlink_social_member_failby_connection_error_and_rollback(int code) throws Exception { + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().unlink())) + .andExpect(MockRestRequestMatchers.header( + "Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")) + .andExpect(MockRestRequestMatchers.header( + "Authorization", "KakaoAK " + oAuthConfig.client().adminKey())) + .andExpect(method(HttpMethod.POST)) + .andRespond(withStatus(HttpStatusCode.valueOf(code))); + + ResultActions result = mockMvc.perform(delete("/members")); + result.andExpect(status().isBadRequest()); + + Optional rollbackMemberOptional = memberSearchRepository.findMember(member.getId()); + assertThat(rollbackMemberOptional).isPresent(); + + Member rollMember = rollbackMemberOptional.get(); + assertAll( + () -> assertThat(rollMember.getSocialId()).isEqualTo(member.getSocialId()), + () -> assertThat(rollMember.getDeletedAt()).isNull() + ); + } + + @DisplayName("방장으로 인해 회원 삭제 조회 실패") + @WithMember + @Test + void unlink_social_member_failby_meber_is_manger() throws Exception { + // given + Room room = RoomFixture.room(); + room.changeManagerNickname(member.getNickname()); + + Participant participant = ParticipantFixture.participant(room, member.getId()); + participant.enableManager(); + roomRepository.save(room); + participantRepository.save(participant); + + // then + mockMvc.perform(delete("/members")) + .andExpect(status().isNotFound()); + } + + @DisplayName("내 정보 조회 성공") + @WithMember + @Test + void search_my_info_success() throws Exception { + // given + Badge birth = BadgeFixture.badge(member.getId(), BadgeType.BIRTH); + Badge level50 = BadgeFixture.badge(member.getId(), BadgeType.LEVEL50); + Badge level10 = BadgeFixture.badge(member.getId(), BadgeType.LEVEL10); + List badges = List.of(birth, level10, level50); + badgeRepository.saveAll(badges); + + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + Item killer = ItemFixture.morningKillerSkin().build(); + itemRepository.saveAll(List.of(night, morning, killer)); + + Inventory nightInven = InventoryFixture.inventory(member.getId(), night); + nightInven.select(member); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(member); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + member.changeDefaultSkintUrl(night); + member.changeDefaultSkintUrl(morning); + memberRepository.flush(); + + // expected + mockMvc.perform(get("/members")) + .andExpect(status().isOk()) + .andExpectAll( + MockMvcResultMatchers.jsonPath("$.nickname").value(member.getNickname()), + MockMvcResultMatchers.jsonPath("$.profileImage").value(member.getProfileImage()), + MockMvcResultMatchers.jsonPath("$.intro").value(member.getIntro()), + MockMvcResultMatchers.jsonPath("$.level").value(member.getTotalCertifyCount() / LEVEL_DIVISOR), + MockMvcResultMatchers.jsonPath("$.exp").value(member.getTotalCertifyCount() % LEVEL_DIVISOR), + + // MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getImage()), + // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), + + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("탄생 축하 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), + MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), + MockMvcResultMatchers.jsonPath("$.nightBug").value(member.getBug().getNightBug()) + ).andDo(print()); + } + + @DisplayName("뱃지없는 내 정보 조회 성공") + @WithMember + @Test + void search_my_info_with_no_badge_success() throws Exception { + // given + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + Item killer = ItemFixture.morningKillerSkin().build(); + itemRepository.saveAll(List.of(night, morning, killer)); + + Inventory nightInven = InventoryFixture.inventory(member.getId(), night); + nightInven.select(member); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(member); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + member.changeDefaultSkintUrl(night); + member.changeDefaultSkintUrl(morning); + + memberRepository.flush(); + + // expected + mockMvc.perform(get("/members")) + .andExpect(status().isOk()) + .andExpectAll( + MockMvcResultMatchers.jsonPath("$.nickname").value(member.getNickname()), + MockMvcResultMatchers.jsonPath("$.profileImage").value(member.getProfileImage()), + MockMvcResultMatchers.jsonPath("$.intro").value(member.getIntro()), + MockMvcResultMatchers.jsonPath("$.level").value(member.getTotalCertifyCount() / LEVEL_DIVISOR), + MockMvcResultMatchers.jsonPath("$.exp").value(member.getTotalCertifyCount() % LEVEL_DIVISOR), + + // MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getImage()), + // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), + + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("탄생 축하 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), + MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), + MockMvcResultMatchers.jsonPath("$.nightBug").value(member.getBug().getNightBug()) + ).andDo(print()); + } + + @DisplayName("친구 정보 조회 성공") + @WithMember + @Test + void search_friend_info_success() throws Exception { + // given + Member friend = MemberFixture.member("123456789"); + memberRepository.save(friend); + + Badge birth = BadgeFixture.badge(friend.getId(), BadgeType.BIRTH); + Badge level10 = BadgeFixture.badge(friend.getId(), BadgeType.LEVEL10); + List badges = List.of(birth, level10); + badgeRepository.saveAll(badges); + + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + Item killer = ItemFixture.morningKillerSkin().build(); + itemRepository.saveAll(List.of(night, morning, killer)); + + Inventory nightInven = InventoryFixture.inventory(friend.getId(), night); + nightInven.select(friend); + + Inventory morningInven = InventoryFixture.inventory(friend.getId(), morning); + morningInven.select(friend); + + Inventory killerInven = InventoryFixture.inventory(friend.getId(), killer); + friend.changeDefaultSkintUrl(morning); + friend.changeDefaultSkintUrl(night); + memberRepository.flush(); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + friend.changeDefaultSkintUrl(morning); + friend.changeDefaultSkintUrl(night); + memberRepository.flush(); + + // expected + mockMvc.perform(get("/members/{memberId}", friend.getId())) + .andExpect(status().isOk()) + .andExpectAll( + MockMvcResultMatchers.jsonPath("$.nickname").value(friend.getNickname()), + MockMvcResultMatchers.jsonPath("$.profileImage").value(friend.getProfileImage()), + MockMvcResultMatchers.jsonPath("$.intro").value(friend.getIntro()), + MockMvcResultMatchers.jsonPath("$.level").value(friend.getTotalCertifyCount() / LEVEL_DIVISOR), + MockMvcResultMatchers.jsonPath("$.exp").value(friend.getTotalCertifyCount() % LEVEL_DIVISOR), + + MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getAwakeImage()), + MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getAwakeImage()), + + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("탄생 축하 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false) + ).andDo(print()); + } + + @DisplayName("회원 정보 찾기 실패로 예외 발생") + @WithMember(id = 123L) + @Test + void search_member_failBy_not_found_member() throws Exception { + // expected + mockMvc.perform(get("/members/{memberId}", 123L)) + .andExpect(status().is4xxClientError()); + } + + @DisplayName("기본 스킨의 갯수가 다를때 예외 발생") + @Test + void search_member_failBy_default_skin_size() throws Exception { + // given + Item night = ItemFixture.nightMageSkin(); + Item morning = ItemFixture.morningSantaSkin().build(); + Item killer = ItemFixture.morningKillerSkin().build(); + itemRepository.saveAll(List.of(night, morning, killer)); + + Inventory nightInven = InventoryFixture.inventory(member.getId(), night); + nightInven.select(member); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(member); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + killerInven.select(member); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // expected + mockMvc.perform(get("/members/{memberId}", 123L)) + .andExpect(status().is4xxClientError()); + } + + @DisplayName("회원 정보 요청 성공") + @WithMember + @ParameterizedTest + @CsvSource({"intro,", ", nickname", ",", "intro, nickname"}) + void member_modify_request_success(String intro, String nickname) throws Exception { + // given + ModifyMemberRequest request = new ModifyMemberRequest(intro, nickname); + MockMultipartFile newProfileImage = + new MockMultipartFile( + "profileImage", + "tooth.png", + "multipart/form-data", + "uploadFile".getBytes(StandardCharsets.UTF_8)); + MockMultipartFile modifyMemberRequest = + new MockMultipartFile( + "modifyMemberRequest", + null, + "application/json", + objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)); + + willReturn(List.of("/main")) + .given(imageService).uploadImages(List.of(newProfileImage), ImageType.PROFILE_IMAGE); + + // expected + mockMvc.perform(multipart(HttpMethod.POST, "/members/modify") + .file(modifyMemberRequest) + .file(newProfileImage) + .contentType("multipart/form-data") + + .characterEncoding("UTF-8")) + .andExpect(status().is2xxSuccessful()) + .andDo(print()); + + } + + @DisplayName("회원 프로필없이 성공 ") + @WithMember + @ParameterizedTest + @CsvSource({"intro,", ", nickname", ",", "intro, nickname"}) + void member_modify_no_image_request_success(String intro, String nickname) throws Exception { + // given + ModifyMemberRequest request = new ModifyMemberRequest(intro, nickname); + MockMultipartFile modifyMemberRequest = + new MockMultipartFile( + "modifyMemberRequest", + null, + "application/json", + objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)); + + willThrow(NullPointerException.class) + .given(imageService).uploadImages(any(), any()); + RankingInfo rankingInfo = MemberMapper.toRankingInfo(member); + redisTemplate.opsForZSet().add("Ranking", rankingInfo, member.getTotalCertifyCount()); + + // expected + mockMvc.perform(multipart(HttpMethod.POST, "/members/modify") + .file(modifyMemberRequest) + .contentType("multipart/form-data") + + .characterEncoding("UTF-8")) + .andExpect(status().is2xxSuccessful()) + .andDo(print()); + + String updateNick = member.getNickname(); + + if (Objects.nonNull(nickname)) { + updateNick = nickname; + } + + Double result = redisTemplate.opsForZSet() + .score("Ranking", new RankingInfo(member.getId(), updateNick, member.getProfileImage())); + assertThat(result).isEqualTo(member.getTotalCertifyCount()); + } +} diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java new file mode 100644 index 00000000..b623bcb9 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -0,0 +1,174 @@ +package com.moabam.api.presentation; + +import static org.mockito.BDDMockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.notification.repository.NotificationRepository; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.infrastructure.fcm.FcmRepository; +import com.moabam.api.infrastructure.fcm.FcmService; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; +import com.moabam.support.snippet.ErrorSnippet; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class NotificationControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + MemberRepository memberRepository; + + @Autowired + RoomRepository roomRepository; + + @Autowired + NotificationRepository notificationRepository; + + @Autowired + ValueRedisRepository valueRedisRepository; + + @Autowired + FcmService fcmService; + + @Autowired + FcmRepository fcmRepository; + + @MockBean + FirebaseMessaging firebaseMessaging; + + Member member; + Member target; + Room room; + String knockKey; + + @BeforeAll + void setUp() { + member = memberRepository.save(MemberFixture.member(1L)); + target = memberRepository.save(MemberFixture.member("socialId")); + room = roomRepository.save(RoomFixture.room()); + knockKey = String.format("roomId=%s_targetId=%s_memberId=%s", room.getId(), target.getId(), member.getId()); + + willReturn(null) + .given(firebaseMessaging) + .sendAsync(any(Message.class)); + } + + @AfterEach + void setDown() { + fcmService.deleteTokenByMemberId(target.getId()); + valueRedisRepository.delete(knockKey); + } + + @WithMember + @DisplayName("POST - 성공적으로 FCM Token을 저장한다. - Void") + @Test + void createFcmToken_success() throws Exception { + // When & Then + mockMvc.perform(post("/notifications") + .param("fcmToken", "FCM-TOKEN")) + .andDo(print()) + .andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember + @DisplayName("POST - FCM Token이 BLANK라 아무일도 일어나지 않는다. - Void") + @Test + void createFcmToken_blank() throws Exception { + // When & Then + mockMvc.perform(post("/notifications") + .param("fcmToken", "")) + .andDo(print()) + .andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember + @DisplayName("GET - 상대에게 콕 알림을 성공적으로 보낸다. - Void") + @Test + void sendKnock_success() throws Exception { + // Given + fcmRepository.saveToken("FCM_TOKEN", target.getId()); + + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications/rooms/roomId/members/memberId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember + @DisplayName("GET - 콕 알림을 보낸 상대가 접속 중이 아니다. - NotFoundException") + @Test + void sendKnock_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications/rooms/roomId/members/memberId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage())); + } + + @WithMember + @DisplayName("GET - 이미 콕 알림을 보낸 대상이다. - ConflictException") + @Test + void sendKnock_ConflictException() throws Exception { + // Given + fcmRepository.saveToken("FCM_TOKEN", target.getId()); + notificationRepository.saveKnock(1L, target.getId(), room.getId()); + + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications/rooms/roomId/members/memberId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_KNOCK.getMessage())); + } +} diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java new file mode 100644 index 00000000..7bbd2b6c --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -0,0 +1,179 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; +import static com.moabam.support.fixture.MemberFixture.*; +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import org.junit.jupiter.api.DisplayName; +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.NullAndEmptySource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.payment.PaymentStatus; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.payment.repository.PaymentSearchRepository; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.TossPaymentException; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +class PaymentControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + MemberService memberService; + + @MockBean + TossPaymentService tossPaymentService; + + @Autowired + PaymentRepository paymentRepository; + + @Autowired + PaymentSearchRepository paymentSearchRepository; + + @Autowired + ProductRepository productRepository; + + @Nested + @DisplayName("결제를 요청한다.") + class Request { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Product product = productRepository.save(bugProduct()); + Payment payment = paymentRepository.save(payment(product)); + PaymentRequest request = new PaymentRequest(ORDER_ID); + + // expected + mockMvc.perform(post("/payments/{paymentId}", payment.getId()) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()); + Payment actual = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(actual.getOrder().getId()).isEqualTo(ORDER_ID); + } + + @DisplayName("결제 요청 바디가 유효하지 않으면 예외가 발생한다.") + @WithMember + @ParameterizedTest + @NullAndEmptySource + void bad_request_body_exception(String orderId) throws Exception { + // given + Long paymentId = 1L; + PaymentRequest request = new PaymentRequest(orderId); + + // expected + mockMvc.perform(post("/payments/{paymentId}", paymentId) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 요청 정보가 아닙니다.")) + .andDo(print()); + } + } + + @Nested + @DisplayName("결제를 승인한다.") + class Confirm { + + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthMember().id(); + Product product = productRepository.save(bugProduct()); + Payment payment = paymentRepository.save(payment(product)); + payment.request(ORDER_ID); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(tossPaymentService.confirm(request)).willReturn(confirmTossPaymentResponse()); + given(memberService.findMember(memberId)).willReturn(member()); + + // expected + mockMvc.perform(post("/payments/confirm") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andDo(print()); + Payment actual = paymentRepository.findById(payment.getId()).orElseThrow(); + assertThat(actual.getStatus()).isEqualTo(PaymentStatus.DONE); + } + + @DisplayName("결제 승인 요청 바디가 유효하지 않으면 예외가 발생한다.") + @WithMember + @ParameterizedTest + @CsvSource(value = { + ", random_order_id_123, 2000", + "payment_key_123, , 2000", + "payment_key_123, random_order_id_123, -1000", + }) + void bad_request_body_exception(String paymentKey, String orderId, int amount) throws Exception { + // given + ConfirmPaymentRequest request = new ConfirmPaymentRequest(paymentKey, orderId, amount); + + // expected + mockMvc.perform(post("/payments/confirm") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 요청 정보가 아닙니다.")) + .andDo(print()); + } + + @DisplayName("토스 결제 승인 요청이 실패하면 예외가 발생한다.") + @WithMember + @Test + void confirm_toss_exception() throws Exception { + // given + Long memberId = getAuthMember().id(); + Product product = productRepository.save(bugProduct()); + Payment payment = paymentRepository.save(payment(product)); + payment.request(ORDER_ID); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(memberService.findMember(memberId)).willReturn(member()); + given(tossPaymentService.confirm(request)).willThrow(TossPaymentException.class); + + // expected + mockMvc.perform(post("/payments/confirm") + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isInternalServerError()) + .andDo(print()); + } + } +} diff --git a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java new file mode 100644 index 00000000..6399e91c --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java @@ -0,0 +1,88 @@ +package com.moabam.api.presentation; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.dto.ranking.RankingInfo; +import com.moabam.api.dto.ranking.UpdateRanking; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.MemberFixture; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class RankingControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + MemberRepository memberRepository; + + @Autowired + RedisTemplate redisTemplate; + + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + + @DisplayName("") + @WithMember + @Test + void top_ranking() throws Exception { + // given + List members = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + Member member = MemberFixture.member(String.valueOf(i + 1)); + members.add(member); + + RankingInfo rankingInfo = new RankingInfo((long)(i + 1), member.getNickname(), member.getProfileImage()); + redisTemplate.opsForZSet().add("Ranking", rankingInfo, i + 1); + } + memberRepository.saveAll(members); + + RankingInfo rankingInfo = new RankingInfo(21L, "Hello22", "123"); + redisTemplate.opsForZSet().add("Ranking", rankingInfo, 20); + RankingInfo rankingInfo2 = new RankingInfo(22L, "Hello23", "123"); + redisTemplate.opsForZSet().add("Ranking", rankingInfo2, 19); + + UpdateRanking myRanking = UpdateRanking.builder() + .score(1L) + .rankingInfo(RankingInfo.builder() + .nickname(members.get(0).getNickname()) + .memberId(members.get(0).getId()) + .image(members.get(0).getProfileImage()).build()) + .build(); + + // when + mockMvc.perform(MockMvcRequestBuilders.get("/rankings")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.topRankings", hasSize(10))) + .andExpect(jsonPath("$.myRanking.nickname", is(members.get(0).getNickname()))) + .andExpect(jsonPath("$.myRanking.rank", is(22))); + + // then + + } + +} diff --git a/src/test/java/com/moabam/api/presentation/ReportControllerTest.java b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java new file mode 100644 index 00000000..4f5c688d --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java @@ -0,0 +1,165 @@ +package com.moabam.api.presentation; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.report.ReportRequest; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.ReportFixture; +import com.moabam.support.fixture.RoomFixture; + +import jakarta.persistence.EntityManagerFactory; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ReportControllerTest extends WithoutFilterSupporter { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + MemberRepository memberRepository; + + @Autowired + RoomRepository roomRepository; + + @Autowired + CertificationRepository certificationRepository; + + @Autowired + RoutineRepository routineRepository; + + @Autowired + EntityManagerFactory entityManagerFactory; + + Member reportedMember; + + @BeforeAll + void setUp() { + reportedMember = MemberFixture.member(); + memberRepository.save(reportedMember); + } + + @DisplayName("방이나 인증 하나 신고") + @WithMember + @ParameterizedTest + @CsvSource({"true, false", "false, true", "true, true"}) + void reports_success(boolean roomFilter, boolean certificationFilter) throws Exception { + // given + String content = "내용"; + Room room = RoomFixture.room(); + Routine routine = RoomFixture.routine(room, content); + Certification certification = RoomFixture.certification(routine); + roomRepository.save(room); + routineRepository.save(routine); + certificationRepository.save(certification); + + Long roomId = null; + Long certificationId = null; + + if (roomFilter) { + roomId = room.getId(); + } + if (certificationFilter) { + certificationId = certification.getId(); + } + + ReportRequest reportRequest = ReportFixture.reportRequest(reportedMember.getId(), roomId, certificationId); + String request = objectMapper.writeValueAsString(reportRequest); + + // expected + mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().is2xxSuccessful()); + } + + @DisplayName("사용자 신고 성공") + @WithMember + @Test + void reports_failBy_subject_null() throws Exception { + // given + Member member = MemberFixture.member("2"); + memberRepository.save(member); + + ReportRequest reportRequest = ReportFixture.reportRequest(member.getId(), null, null); + String request = objectMapper.writeValueAsString(reportRequest); + + // expected + mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().is2xxSuccessful()); + } + + @DisplayName("회원 조회 실패로 신고 실패") + @WithMember + @Test + void reports_failBy_member() throws Exception { + // given + Member newMember = MemberFixture.member("9999"); + memberRepository.save(newMember); + + newMember.delete(LocalDateTime.now()); + memberRepository.flush(); + memberRepository.delete(newMember); + memberRepository.flush(); + + ReportRequest reportRequest = ReportFixture.reportRequest(newMember.getId(), 1L, 1L); + String request = objectMapper.writeValueAsString(reportRequest); + + // expected + mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().is4xxClientError()); + } + + @DisplayName("방이나 인증 하나 신고 실패") + @WithMember + @ParameterizedTest + @CsvSource({"12394,", ",123415", "12394, 123415"}) + void reports_failBy_room_certification(Long roomId, Long certificationId) throws Exception { + // given + ReportRequest reportRequest = ReportFixture.reportRequest(reportedMember.getId(), roomId, + certificationId); + String request = objectMapper.writeValueAsString(reportRequest); + + // expected + mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().is4xxClientError()); + } +} diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java new file mode 100644 index 00000000..f3de23a6 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -0,0 +1,1592 @@ +package com.moabam.api.presentation; + +import static com.moabam.api.domain.room.RoomType.*; +import static org.assertj.core.api.Assertions.*; +import static org.springframework.http.MediaType.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.InventoryRepository; +import com.moabam.api.domain.item.repository.ItemRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; +import com.moabam.api.domain.room.repository.CertificationRepository; +import com.moabam.api.domain.room.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.room.repository.DailyRoomCertificationRepository; +import com.moabam.api.domain.room.repository.ParticipantRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; +import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.api.dto.room.EnterRoomRequest; +import com.moabam.api.dto.room.ModifyRoomRequest; +import com.moabam.global.common.util.ClockHolder; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; +import com.moabam.support.fixture.BugFixture; +import com.moabam.support.fixture.InventoryFixture; +import com.moabam.support.fixture.ItemFixture; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class RoomControllerTest extends WithoutFilterSupporter { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoutineRepository routineRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CertificationRepository certificationRepository; + + @Autowired + private DailyMemberCertificationRepository dailyMemberCertificationRepository; + + @Autowired + private DailyRoomCertificationRepository dailyRoomCertificationRepository; + + @Autowired + private ParticipantSearchRepository participantSearchRepository; + + @Autowired + private ItemRepository itemRepository; + + @Autowired + private InventoryRepository inventoryRepository; + + @Autowired + private ClockHolder clockHolder; + + Member member; + + @BeforeAll + void setUp() { + member = MemberFixture.member(); + memberRepository.save(member); + } + + @AfterEach + void cleanUp() { + while (member.getCurrentMorningCount() > 0) { + member.exitRoom(MORNING); + } + + while (member.getCurrentNightCount() > 0) { + member.exitRoom(NIGHT); + } + } + + @DisplayName("비밀번호 없는 방 생성 성공") + @WithMember + @Test + void create_room_no_password_success() throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andDo(print()); + + assertThat(roomRepository.findAll()).hasSize(1); + assertThat(roomRepository.findAll().get(0).getTitle()).isEqualTo("재윤과 앵맹이의 방임"); + assertThat(roomRepository.findAll().get(0).getPassword()).isNull(); + } + + @DisplayName("비밀번호 있는 방 생성 성공") + @WithMember + @ParameterizedTest + @CsvSource({ + "1234", "12345678", "98765" + }) + void create_room_with_password_success(String password) throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 있는 재맹의 방임", password, routines, MORNING, 10, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isCreated()) + .andDo(print()); + + assertThat(roomRepository.findAll()).hasSize(1); + assertThat(roomRepository.findAll().get(0).getTitle()).isEqualTo("비번 있는 재맹의 방임"); + assertThat(roomRepository.findAll().get(0).getPassword()).isEqualTo(password); + } + + @DisplayName("올바르지 않은 비밀번호 방 생성시 예외 발생") + @ParameterizedTest + @CsvSource({ + "1", "12", "123", "123456789", "abc" + }) + void create_room_with_wrong_password_fail(String password) throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 있는 재윤과 앵맹이의 방임", password, routines, MORNING, 10, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("Routine 갯수를 초과한 방 생성시 예외 발생") + @Test + void create_room_with_too_many_routine_fail() throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + routines.add("밥 먹기"); + routines.add("코드 리뷰 달기"); + routines.add("책 읽기"); + routines.add("산책 하기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("Routine 없는 방 생성시 예외 발생") + @Test + void create_room_with_no_routine_fail() throws Exception { + // given + List routines = new ArrayList<>(); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("올바르지 못한 시간으로 아침 방 생성시 예외 발생") + @ParameterizedTest + @CsvSource({ + "1", "3", "11", "12", "20" + }) + void create_morning_room_wrong_certify_time_fail(int certifyTime) throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, certifyTime, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("올바르지 못한 시간으로 저녁 방 생성시 에외 발생") + @ParameterizedTest + @CsvSource({ + "19", "3", "6", "9" + }) + void create_night_room_wrong_certify_time_fail(int certifyTime) throws Exception { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "비번 없는 재윤과 앵맹이의 방임", null, routines, NIGHT, certifyTime, 4); + String json = objectMapper.writeValueAsString(createRoomRequest); + + // expected + mockMvc.perform(post("/rooms") + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("방 수정 성공 - 방장일 경우") + @WithMember(id = 1L) + @Test + void modify_room_success() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + List routines = RoomFixture.routines(room); + + Participant participant = RoomFixture.participant(room, 1L); + participant.enableManager(); + + roomRepository.save(room); + routineRepository.saveAll(routines); + participantRepository.save(participant); + + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "공지공지", "4567", 10, 7); + String json = objectMapper.writeValueAsString(modifyRoomRequest); + + // expected + mockMvc.perform(put("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andDo(print()); + + Room modifiedRoom = roomRepository.findById(room.getId()).orElseThrow(); + List modifiedRoutines = routineRepository.findAllByRoomId(room.getId()); + + assertThat(modifiedRoom.getTitle()).isEqualTo("수정할 방임!"); + assertThat(modifiedRoom.getCertifyTime()).isEqualTo(10); + assertThat(modifiedRoom.getPassword()).isEqualTo("4567"); + assertThat(modifiedRoom.getAnnouncement()).isEqualTo("공지공지"); + assertThat(modifiedRoom.getMaxUserCount()).isEqualTo(7); + assertThat(modifiedRoutines).hasSize(2); + } + + @DisplayName("방 수정 실패 - 방장 아닐 경우") + @WithMember(id = 1L) + @Test + void unauthorized_modify_room_fail() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + Participant participant = RoomFixture.participant(room, 1L); + + roomRepository.save(room); + participantRepository.save(participant); + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "방 공지", "1234", 9, 7); + String json = objectMapper.writeValueAsString(modifyRoomRequest); + String message = "{\"message\":\"방장이 아닌 사용자는 방을 수정할 수 없습니다.\"}"; + + // expected + mockMvc.perform(put("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()) + .andExpect(content().json(message)) + .andDo(print()); + } + + @DisplayName("방 수정 실패 - 이미 한 참여자가 인증하고 방의 인증 시간을 바꾸려고 할때 예외 처리") + @WithMember(id = 1L) + @Test + void room_certify_time_modification_fail() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + room = roomRepository.save(room); + + Member member2 = MemberFixture.member("12313123"); + member2 = memberRepository.save(member2); + + Participant participant1 = RoomFixture.participant(room, 1L); + participant1.enableManager(); + + Participant participant2 = RoomFixture.participant(room, member2.getId()); + + participantRepository.saveAll(List.of(participant1, participant2)); + + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(member2.getId(), + room.getId(), participant2); + + dailyMemberCertificationRepository.save(dailyMemberCertification); + + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "방 공지", "1234", 10, 7); + String json = objectMapper.writeValueAsString(modifyRoomRequest); + + // expected + mockMvc.perform(put("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("비밀번호 있는 방 참여 성공") + @WithMember(id = 1L) + @Test + void enter_room_with_password_success() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("7777") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("7777"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // expected + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("비밀번호 없는 방 참여 성공") + @WithMember(id = 1L) + @Test + void enter_room_with_no_password_success() throws Exception { + // given + Room room = RoomFixture.room(); + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest(null); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // expected + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 참여 후 인원수 증가 테스트") + @WithMember(id = 1L) + @Test + void enter_and_increase_room_user_count() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // when + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()); + + Room findRoom = roomRepository.findById(room.getId()).orElseThrow(); + + // then + assertThat(findRoom.getCurrentUserCount()).isEqualTo(2); + } + + @DisplayName("아침 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @WithMember(id = 1L) + @Test + void enter_and_increase_morning_room_count() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // when + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()); + + Member getMember = memberRepository.findById(1L).orElseThrow(); + + // then + assertThat(getMember.getCurrentMorningCount()).isEqualTo(1); + assertThat(getMember.getCurrentNightCount()).isZero(); + } + + @DisplayName("저녁 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @WithMember(id = 1L) + @Test + void enter_and_increase_night_room_count() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(5) + .build(); + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // when + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()); + + Member getMember = memberRepository.findById(1L).orElseThrow(); + + // then + assertThat(getMember.getCurrentNightCount()).isEqualTo(1); + assertThat(getMember.getCurrentMorningCount()).isZero(); + } + + @DisplayName("사용자의 아침 방 입장 횟수 3일시 예외 처리") + @Test + void enter_and_morning_room_over_three_fail() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + for (int i = 0; i < 3; i++) { + member.enterRoom(MORNING); + } + + memberRepository.save(member); + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // when + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("사용자의 저녁 방 입장 횟수 3일시 예외 처리") + @Test + void enter_and_night_room_over_three_fail() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(NIGHT) + .certifyTime(22) + .maxUserCount(5) + .build(); + + for (int i = 0; i < 3; i++) { + member.enterRoom(NIGHT); + } + + memberRepository.save(member); + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + + // when + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("비밀번호 불일치 방 참여시 예외 발생") + @WithMember(id = 1L) + @Test + void enter_room_wrong_password_fail() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("7777") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + Member member = Member.builder() + .id(1L) + .socialId("1") + .bug(BugFixture.bug()) + .build(); + + memberRepository.save(member); + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("1234"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + String message = "{\"message\":\"방의 비밀번호가 일치하지 않습니다.\"}"; + + // expected + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(message)) + .andDo(print()); + } + + @DisplayName("인원수가 모두 찬 방 참여시 예외 발생") + @WithMember(id = 1L) + @Test + void enter_max_user_room_fail() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("7777") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + for (int i = 0; i < 4; i++) { + room.increaseCurrentUserCount(); + } + + roomRepository.save(room); + EnterRoomRequest enterRoomRequest = new EnterRoomRequest("7777"); + String json = objectMapper.writeValueAsString(enterRoomRequest); + String message = "{\"message\":\"방의 인원수가 찼습니다.\"}"; + + // expected + mockMvc.perform(post("/rooms/" + room.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isBadRequest()) + .andExpect(content().json(message)) + .andDo(print()); + } + + @DisplayName("일반 사용자의 방 나가기 성공") + @WithMember(id = 1L) + @Test + void no_manager_exit_room_success() throws Exception { + // given + Room room = Room.builder() + .title("5명이 있는 방~") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(8) + .build(); + + Participant participant = RoomFixture.participant(room, 1L); + + for (int i = 0; i < 4; i++) { + room.increaseCurrentUserCount(); + } + + roomRepository.save(room); + participantRepository.save(participant); + + // expected + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isOk()) + .andDo(print()); + + Room findRoom = roomRepository.findById(room.getId()).orElseThrow(); + List deletedParticipant = participantRepository.findAll(); + + assertThat(findRoom.getCurrentUserCount()).isEqualTo(4); + assertThat(deletedParticipant).hasSize(1); + assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull(); + assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull(); + } + + @DisplayName("방장의 방 나가기 - 방 삭제 성공") + @WithMember(id = 1L) + @Test + void manager_delete_room_success() throws Exception { + // given + Room room = Room.builder() + .title("1명이 있는 방~") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(8) + .build(); + + List routines = RoomFixture.routines(room); + + Participant participant = RoomFixture.participant(room, 1L); + participant.enableManager(); + + roomRepository.save(room); + routineRepository.saveAll(routines); + participantRepository.save(participant); + + // expected + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isOk()) + .andDo(print()); + + List deletedParticipant = participantRepository.findAll(); + + assertThat(deletedParticipant).hasSize(1); + assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull(); + assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull(); + } + + @DisplayName("방장이 위임하지 않고 방 나가기 실패") + @WithMember(id = 1L) + @Test + void manager_exit_room_fail() throws Exception { + // given + Room room = Room.builder() + .title("7명이 있는 방~") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(10) + .build(); + + Participant participant = RoomFixture.participant(room, 1L); + participant.enableManager(); + + for (int i = 0; i < 6; i++) { + room.increaseCurrentUserCount(); + } + + roomRepository.save(room); + participantRepository.save(participant); + String message = "{\"message\":\"인원수가 2명 이상일 때는 방장을 위임해야 합니다.\"}"; + + // expected + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isBadRequest()) + .andExpect(content().json(message)) + .andDo(print()); + } + + @DisplayName("아침 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @WithMember(id = 1L) + @Test + void exit_and_decrease_morning_room_count() throws Exception { + // given + Room room = RoomFixture.room(); + + Participant participant = RoomFixture.participant(room, 1L); + + for (int i = 0; i < 3; i++) { + member.enterRoom(RoomType.MORNING); + } + + memberRepository.save(member); + roomRepository.save(room); + participantRepository.save(participant); + + // when + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isOk()); + + Member getMember = memberRepository.findById(1L).orElseThrow(); + + // then + assertThat(getMember.getCurrentMorningCount()).isEqualTo(2); + } + + @DisplayName("저녁 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @WithMember(id = 1L) + @Test + void exit_and_decrease_night_room_count() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(NIGHT) + .certifyTime(23) + .maxUserCount(5) + .build(); + + Participant participant = RoomFixture.participant(room, 1L); + + for (int i = 0; i < 3; i++) { + member.enterRoom(NIGHT); + } + + memberRepository.save(member); + roomRepository.save(room); + participantRepository.save(participant); + + // when + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isOk()); + + Member getMember = memberRepository.findById(1L).orElseThrow(); + + // then + assertThat(getMember.getCurrentNightCount()).isEqualTo(2); + } + + @DisplayName("방 상세 정보 조회 성공 테스트") + @WithMember(id = 1L) + @Test + void get_room_details_test() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(NIGHT) + .certifyTime(23) + .maxUserCount(5) + .build(); + + room.increaseCurrentUserCount(); + room.increaseCurrentUserCount(); + + List routines = RoomFixture.routines(room); + + Participant participant1 = RoomFixture.participant(room, 1L); + participant1.enableManager(); + + Member member2 = MemberFixture.member("2"); + Member member3 = MemberFixture.member("3"); + + roomRepository.save(room); + routineRepository.saveAll(routines); + member2 = memberRepository.save(member2); + member3 = memberRepository.save(member3); + + Item item = ItemFixture.nightMageSkin(); + + Inventory inventory1 = InventoryFixture.inventory(1L, item); + Inventory inventory2 = InventoryFixture.inventory(member2.getId(), item); + Inventory inventory3 = InventoryFixture.inventory(member3.getId(), item); + inventory1.select(member); + inventory2.select(member2); + inventory3.select(member3); + + itemRepository.save(item); + inventoryRepository.saveAll(List.of(inventory1, inventory2, inventory3)); + + Participant participant2 = RoomFixture.participant(room, member2.getId()); + Participant participant3 = RoomFixture.participant(room, member3.getId()); + + participantRepository.save(participant1); + participantRepository.save(participant2); + participantRepository.save(participant3); + + Certification certification1 = Certification.builder() + .routine(routines.get(0)) + .memberId(member.getId()) + .image("member1Image") + .build(); + + Certification certification2 = Certification.builder() + .routine(routines.get(1)) + .memberId(member.getId()) + .image("member2Image") + .build(); + + certificationRepository.save(certification1); + certificationRepository.save(certification2); + + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(member.getId(), + room.getId(), participant1); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(room.getId(), + LocalDate.now()); + dailyRoomCertificationRepository.save(dailyRoomCertification); + + DailyRoomCertification dailyRoomCertification1 = RoomFixture.dailyRoomCertification(room.getId(), + LocalDate.now().minusDays(3)); + dailyRoomCertificationRepository.save(dailyRoomCertification1); + + // expected + mockMvc.perform(get("/rooms/" + room.getId() + "/" + LocalDate.now())) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 추방 성공") + @WithMember(id = 1L) + @Test + void deport_member_success() throws Exception { + // given + Room room = RoomFixture.room(); + Member member = MemberFixture.member("1234"); + memberRepository.save(member); + + Participant memberParticipant = RoomFixture.participant(room, member.getId()); + Participant managerParticipant = RoomFixture.participant(room, 1L); + managerParticipant.enableManager(); + + room.increaseCurrentUserCount(); + + roomRepository.save(room); + participantRepository.save(memberParticipant); + participantRepository.save(managerParticipant); + + // expected + mockMvc.perform(delete("/rooms/" + room.getId() + "/members/" + member.getId())) + .andExpect(status().isOk()) + .andDo(print()); + roomRepository.flush(); + + Room getRoom = roomRepository.findById(room.getId()).orElseThrow(); + Participant getMemberParticipant = participantRepository.findById(memberParticipant.getId()).orElseThrow(); + + assertThat(getRoom.getCurrentUserCount()).isEqualTo(1); + assertThat(getMemberParticipant.getDeletedAt()).isNotNull(); + assertThat(participantSearchRepository.findOne(member.getId(), room.getId())).isEmpty(); + } + + @DisplayName("방장 본인 추방 시도 - 예외 처리") + @WithMember(id = 1L) + @Test + void deport_self_fail() throws Exception { + // given + Room room = RoomFixture.room(); + + Participant managerParticipant = RoomFixture.participant(room, member.getId()); + managerParticipant.enableManager(); + + roomRepository.save(room); + participantRepository.save(managerParticipant); + + // expected + mockMvc.perform(delete("/rooms/" + room.getId() + "/members/" + member.getId())) + .andExpect(status().isBadRequest()) + .andDo(print()); + } + + @DisplayName("방장 위임 성공") + @WithMember(id = 1L) + @Test + void mandate_manager_success() throws Exception { + // given + Member member2 = MemberFixture.member("1234"); + memberRepository.save(member2); + + Room room = RoomFixture.room(); + Participant participant1 = RoomFixture.participant(room, member.getId()); + participant1.enableManager(); + Participant participant2 = RoomFixture.participant(room, member2.getId()); + + roomRepository.save(room); + participantRepository.save(participant1); + participantRepository.save(participant2); + + // expected + mockMvc.perform(put("/rooms/" + room.getId() + "/members/" + member2.getId() + "/mandate")) + .andExpect(status().isOk()) + .andDo(print()); + + Room savedRoom = roomRepository.findById(room.getId()).orElseThrow(); + Participant savedParticipant1 = participantRepository.findById(participant1.getId()).orElseThrow(); + Participant savedParticipant2 = participantRepository.findById(participant2.getId()).orElseThrow(); + + assertThat(savedRoom.getManagerNickname()).isEqualTo(member2.getNickname()); + assertThat(savedParticipant1.isManager()).isFalse(); + assertThat(savedParticipant2.isManager()).isTrue(); + } + + @DisplayName("현재 참여중인 모든 방 조회 성공 - 첫번째 방은 개인과 방 모두 인증 성공") + @WithMember(id = 1L) + @Test + void get_all_my_rooms_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", MORNING, 10); + Room room2 = RoomFixture.room("아침 - 두 번째 방", MORNING, 8); + Room room3 = RoomFixture.room("밤 - 세 번째 방", NIGHT, 22); + + Participant participant1 = RoomFixture.participant(room1, 1L); + Participant participant2 = RoomFixture.participant(room2, 1L); + Participant participant3 = RoomFixture.participant(room3, 1L); + + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(1L, 1L, participant1); + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(1L, LocalDate.now()); + + roomRepository.saveAll(List.of(room1, room2, room3)); + participantRepository.saveAll(List.of(participant1, participant2, participant3)); + dailyMemberCertificationRepository.save(dailyMemberCertification); + dailyRoomCertificationRepository.save(dailyRoomCertification); + + // expected + mockMvc.perform(get("/rooms/my-join")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 참여 기록 조회 성공") + @WithMember(id = 1L) + @Test + void get_join_history_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", MORNING, 10); + Room room2 = RoomFixture.room("아침 - 두 번째 방", MORNING, 8); + Room room3 = RoomFixture.room("밤 - 세 번째 방", NIGHT, 22); + + Participant participant1 = RoomFixture.participant(room1, 1L); + Participant participant2 = RoomFixture.participant(room2, 1L); + Participant participant3 = RoomFixture.participant(room3, 1L); + + roomRepository.saveAll(List.of(room1, room2, room3)); + participantRepository.saveAll(List.of(participant1, participant2, participant3)); + + participant3.removeRoom(); + participantRepository.flush(); + participantRepository.delete(participant3); + + // expected + mockMvc.perform(get("/rooms/join-history")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("참여중이지 않은 방에 대한 확인 성공") + @WithMember + @Test + void check_if_participant_false_success() throws Exception { + // given + Room room = RoomFixture.room(); + Room savedRoom = roomRepository.save(room); + + // expected + mockMvc.perform(get("/rooms/" + savedRoom.getId() + "/check")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("참여중이지 않은 방의 정보 불러오기 성공") + @Test + void get_un_joined_room_details() throws Exception { + // given + Room room = RoomFixture.room("테스트 방", NIGHT, 21); + Room savedRoom = roomRepository.save(room); + + Member member1 = MemberFixture.member("901010"); + member1 = memberRepository.save(member1); + + Item item = ItemFixture.nightMageSkin(); + + Inventory inventory = InventoryFixture.inventory(member1.getId(), item); + inventory.select(member1); + + itemRepository.save(item); + inventoryRepository.save(inventory); + + Participant participant = RoomFixture.participant(savedRoom, member1.getId()); + participantRepository.save(participant); + + Routine routine1 = RoomFixture.routine(savedRoom, "물 마시기"); + Routine routine2 = RoomFixture.routine(savedRoom, "커피 마시기"); + routineRepository.saveAll(List.of(routine1, routine2)); + + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(member1.getId(), + savedRoom.getId(), participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + // expected + mockMvc.perform(get("/rooms/" + savedRoom.getId() + "/un-joined")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("참여중인 방에 대한 확인 성공") + @WithMember + @Test + void check_if_participant_true_success() throws Exception { + // given + Room room = RoomFixture.room(); + Room savedRoom = roomRepository.save(room); + + Participant participant = RoomFixture.participant(room, 1L); + participantRepository.save(participant); + + // expected + mockMvc.perform(get("/rooms/" + savedRoom.getId() + "/check")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("아침, 저녁 방 전체 조회 성공 - 첫 번째 조회, 다음 페이지 있음") + @WithMember(id = 1L) + @Test + void search_all_morning_night_rooms_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234"); + Room room2 = RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("아침, 저녁 방 전체 조회 성공 - 마지막 조회, 다음 페이지 없음") + @WithMember(id = 1L) + @Test + void search_last_page_all_morning_night_rooms_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234"); + Room room2 = RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms?roomId=5")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("아침 방 전체 조회 성공 - 첫 번째 조회, 다음 페이지 없음") + @WithMember(id = 1L) + @Test + void search_last_page_all_morning_rooms_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234"); + Room room2 = RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms?roomType=MORNING")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 검색 조회 성공 - 키워드만 존재") + @WithMember(id = 1L) + @Test + void search_first_page_all_rooms_by_keyword_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234"); + Room room2 = RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms/search?keyword=아침")) + .andExpect(status().isOk()) + .andDo(print()); + + mockMvc.perform(get("/rooms/search?keyword=방12")) + .andExpect(status().isOk()) + .andDo(print()); + + mockMvc.perform(get("/rooms/search?keyword=방")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 검색 조회 성공 - 키워드 + 방 타입 존재") + @WithMember(id = 1L) + @Test + void search_first_page_all_rooms_by_keyword_roomType_success() throws Exception { + // given + Room room1 = RoomFixture.room("아침 - 첫 번째 방", RoomType.MORNING, 10, "1234"); + Room room2 = RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("아침 - 아홉 번째 방", RoomType.MORNING, 4); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("아침 - 열둘 번째 방", RoomType.MORNING, 10); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms/search?keyword=번째&roomType=MORNING")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 검색 조회 성공 - 키워드 + 방 타입 + 추가 페이지 존재X") + @WithMember(id = 1L) + @Test + void search_first_page_all_rooms_by_keyword_roomType_roomId_success() throws Exception { + // given + Room room1 = RoomFixture.room("밤 - 첫 번째 방", RoomType.NIGHT, 1, "1234"); + Room room2 = RoomFixture.room("밤 - 두 번째 방", RoomType.NIGHT, 1); + Room room3 = RoomFixture.room("밤 - 세 번째 방", RoomType.NIGHT, 22); + Room room4 = RoomFixture.room("아침 - 네 번째 방", RoomType.MORNING, 7); + Room room5 = RoomFixture.room("밤 - 다섯 번째 방", RoomType.NIGHT, 23, "5869"); + Room room6 = RoomFixture.room("아침 - 여섯 번째 방", RoomType.MORNING, 8); + Room room7 = RoomFixture.room("밤 - 일곱 번째 방", RoomType.NIGHT, 20); + Room room8 = RoomFixture.room("밤 - 여덟 번째 방", RoomType.NIGHT, 1, "5236"); + Room room9 = RoomFixture.room("밤 - 아홉 번째 방", RoomType.NIGHT, 1, "5236"); + Room room10 = RoomFixture.room("밤 - 열 번째 방", RoomType.NIGHT, 1, "97979"); + Room room11 = RoomFixture.room("밤 - 열하나 번째 방", RoomType.NIGHT, 22); + Room room12 = RoomFixture.room("밤 - 열둘 번째 방", RoomType.NIGHT, 1); + Room room13 = RoomFixture.room("밤 - 열셋 번째 방", RoomType.NIGHT, 2); + Room room14 = RoomFixture.room("밤 - 열넷 번째 방", RoomType.NIGHT, 21); + + Routine routine1 = RoomFixture.routine(room1, "방1의 루틴1"); + Routine routine2 = RoomFixture.routine(room1, "방1의 루틴2"); + + Routine routine3 = RoomFixture.routine(room2, "방2의 루틴1"); + Routine routine4 = RoomFixture.routine(room2, "방2의 루틴2"); + + Routine routine5 = RoomFixture.routine(room3, "방3의 루틴1"); + Routine routine6 = RoomFixture.routine(room3, "방3의 루틴2"); + + Routine routine7 = RoomFixture.routine(room4, "방4의 루틴1"); + Routine routine8 = RoomFixture.routine(room4, "방4의 루틴2"); + + Routine routine9 = RoomFixture.routine(room5, "방5의 루틴1"); + Routine routine10 = RoomFixture.routine(room5, "방5의 루틴2"); + + Routine routine11 = RoomFixture.routine(room6, "방6의 루틴1"); + Routine routine12 = RoomFixture.routine(room6, "방6의 루틴2"); + + Routine routine13 = RoomFixture.routine(room7, "방7의 루틴1"); + Routine routine14 = RoomFixture.routine(room7, "방7의 루틴2"); + + Routine routine15 = RoomFixture.routine(room8, "방8의 루틴1"); + Routine routine16 = RoomFixture.routine(room8, "방8의 루틴2"); + + Routine routine17 = RoomFixture.routine(room9, "방9의 루틴1"); + Routine routine18 = RoomFixture.routine(room9, "방9의 루틴2"); + + Routine routine19 = RoomFixture.routine(room10, "방10의 루틴1"); + Routine routine20 = RoomFixture.routine(room10, "방10의 루틴2"); + + Routine routine21 = RoomFixture.routine(room11, "방11의 루틴1"); + Routine routine22 = RoomFixture.routine(room11, "방11의 루틴2"); + + Routine routine23 = RoomFixture.routine(room12, "방12의 루틴1"); + Routine routine24 = RoomFixture.routine(room12, "방12의 루틴2"); + + Routine routine25 = RoomFixture.routine(room13, "방13의 루틴1"); + Routine routine26 = RoomFixture.routine(room13, "방13의 루틴2"); + + Routine routine27 = RoomFixture.routine(room14, "방14의 루틴1"); + Routine routine28 = RoomFixture.routine(room14, "방14의 루틴2"); + + roomRepository.saveAll( + List.of(room1, room2, room3, room4, room5, room6, room7, room8, room9, room10, room11, room12, room13, + room14)); + + routineRepository.saveAll( + List.of(routine1, routine2, routine3, routine4, routine5, routine6, routine7, routine8, routine9, routine10, + routine11, routine12, routine13, routine14, routine15, routine16, routine17, routine18, routine19, + routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); + + // expected + mockMvc.perform(get("/rooms/search?keyword=루틴&roomType=NIGHT&roomId=3")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 수정전 정보 불러오기 성공") + @WithMember(id = 1L) + @Test + void get_room_details_before_modification_success() throws Exception { + // given + Member member2 = MemberFixture.member("123"); + Member member3 = MemberFixture.member("456"); + member2 = memberRepository.save(member2); + member3 = memberRepository.save(member3); + + Room room = RoomFixture.room("수정 전 방 제목", MORNING, 10, "1234"); + Participant participant1 = RoomFixture.participant(room, 1L); + participant1.enableManager(); + Participant participant2 = RoomFixture.participant(room, member2.getId()); + Participant participant3 = RoomFixture.participant(room, member3.getId()); + List routines = RoomFixture.routines(room); + + roomRepository.save(room); + participantRepository.saveAll(List.of(participant1, participant2, participant3)); + routineRepository.saveAll(routines); + + // expected + mockMvc.perform(get("/rooms/" + room.getId())) + .andExpect(status().isOk()) + .andDo(print()); + } +} diff --git a/src/test/java/com/moabam/global/common/handler/AuthArgumentResolverTest.java b/src/test/java/com/moabam/global/common/handler/AuthArgumentResolverTest.java new file mode 100644 index 00000000..a42203d9 --- /dev/null +++ b/src/test/java/com/moabam/global/common/handler/AuthArgumentResolverTest.java @@ -0,0 +1,116 @@ +package com.moabam.global.common.handler; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.DisplayName; +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.junit.jupiter.MockitoExtension; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.ModelAndViewContainer; + +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.annotation.Auth; +import com.moabam.global.auth.handler.AuthArgumentResolver; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; + +@ExtendWith(MockitoExtension.class) +class AuthArgumentResolverTest { + + @InjectMocks + AuthArgumentResolver authArgumentResolver; + + @Nested + @DisplayName("제공 파라미터 검증") + class SupportParameter { + + @DisplayName("파라미터 제공 성공") + @Test + void support_parameter_success() { + // given + MethodParameter parameter = mock(MethodParameter.class); + + willReturn(mock(Auth.class)) + .given(parameter).getParameterAnnotation(any()); + willReturn(AuthMember.class) + .given(parameter).getParameterType(); + + // when + boolean support = authArgumentResolver.supportsParameter(parameter); + + // then + assertThat(support).isTrue(); + } + + @DisplayName("어노테이션이 없어서 지원 실패") + @Test + void support_paramter_failby_no_annotation() { + // given + MethodParameter parameter = mock(MethodParameter.class); + + willReturn(null) + .given(parameter).getParameterAnnotation(any()); + + // when + boolean support = authArgumentResolver.supportsParameter(parameter); + + // then + assertThat(support).isFalse(); + } + + @DisplayName("AuthMember 클래스로 받지 않았을 때 실패") + @Test + void support_paramter_failby_not_authmember() { + // given + MethodParameter parameter = mock(MethodParameter.class); + + willReturn(mock(Auth.class)) + .given(parameter).getParameterAnnotation(any()); + willReturn(Member.class) + .given(parameter).getParameterType(); + + // when + boolean support = authArgumentResolver.supportsParameter(parameter); + + // then + assertThat(support).isFalse(); + } + } + + @DisplayName("값 변환한다") + @Nested + class Resolve { + + @DisplayName("값 변환 성공") + @Test + void resolve_argument_success() { + MethodParameter parameter = mock(MethodParameter.class); + ModelAndViewContainer mavContainer = mock(ModelAndViewContainer.class); + NativeWebRequest webRequest = mock(NativeWebRequest.class); + WebDataBinderFactory binderFactory = mock(WebDataBinderFactory.class); + + AuthorizationThreadLocal.setAuthMember(new AuthMember(1L, "park", Role.USER)); + + Object object = + authArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + assertAll( + () -> assertThat(object).isNotNull(), + () -> { + AuthMember authMember = (AuthMember)object; + + assertThat(authMember.id()).isEqualTo(1L); + } + ); + } + } + +} diff --git a/src/test/java/com/moabam/global/common/handler/PathResolverTest.java b/src/test/java/com/moabam/global/common/handler/PathResolverTest.java new file mode 100644 index 00000000..69ec965f --- /dev/null +++ b/src/test/java/com/moabam/global/common/handler/PathResolverTest.java @@ -0,0 +1,65 @@ +package com.moabam.global.common.handler; + +import static com.moabam.api.domain.member.Role.*; +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.http.HttpMethod.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.auth.handler.PathResolver; + +class PathResolverTest { + + @DisplayName("path 기본 생성 성공") + @Test + void create_basic_path_success() { + // given + PathResolver.Path path = PathResolver.Path.builder() + .uri("/") + .build(); + + assertAll( + () -> assertThat(path.uri()).isEqualTo("/"), + () -> assertThat(path.roles()).contains(USER), + () -> assertThat(path.httpMethods()).contains(GET, PUT, DELETE, POST, PATCH) + ); + } + + @DisplayName("method직접 설정 생성 성공") + @Test + void create_custom_mehtod_path_success() { + // given + PathResolver.Path path = PathResolver.Path.builder() + .uri("/") + .httpMethod(GET) + .httpMethods(List.of(POST, DELETE)) + .build(); + + assertAll( + () -> assertThat(path.uri()).isEqualTo("/"), + () -> assertThat(path.roles()).contains(USER), + () -> assertThat(path.httpMethods()).contains(GET, DELETE, POST) + ); + } + + @DisplayName("role직접 설정 생성 성공") + @Test + void create_role_mehtod_path_success() { + // given + PathResolver.Path path = PathResolver.Path.builder() + .uri("/") + .role(USER) + .roles(List.of(BLACK)) + .build(); + + assertAll( + () -> assertThat(path.uri()).isEqualTo("/"), + () -> assertThat(path.roles()).contains(USER, BLACK), + () -> assertThat(path.httpMethods()).contains(GET, PUT, DELETE, POST, PATCH) + ); + } +} diff --git a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java new file mode 100644 index 00000000..2f7e2d09 --- /dev/null +++ b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java @@ -0,0 +1,59 @@ +package com.moabam.global.common.util; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.servlet.http.Cookie; + +@ExtendWith(MockitoExtension.class) +class CookieMakeTest { + + String domain = "test"; + + @DisplayName("prod환경에서 cookie 생성 테스트") + @Test + void create_test() { + // Given + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000, domain); + + // When + Then + assertAll( + () -> assertThat(cookie.getSecure()).isTrue(), + () -> assertThat(cookie.getSecure()).isTrue(), + () -> assertThat(cookie.getPath()).isEqualTo("/"), + () -> assertThat(cookie.getMaxAge()).isEqualTo(10000), + () -> assertThat(cookie.getAttribute("SameSite")).isEqualTo("None") + ); + } + + @DisplayName("") + @Test + void delete_test() { + // given + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000, domain); + + // when + Cookie deletedCookie = CookieUtils.deleteCookie(cookie); + + // then + assertAll( + () -> assertThat(deletedCookie.getMaxAge()).isZero(), + () -> assertThat(deletedCookie.getPath()).isEqualTo("/") + ); + } + + @DisplayName("") + @Test + void typeCookie_create_test() { + // Given + When + Cookie cookie = CookieUtils.typeCookie("Bearer", 10000, domain); + + // then + assertThat(cookie.getName()).isEqualTo("token_type"); + } +} diff --git a/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java b/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java new file mode 100644 index 00000000..cfaac3cd --- /dev/null +++ b/src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java @@ -0,0 +1,41 @@ +package com.moabam.global.common.util; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; + +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +class UrlSubstringParserTest { + + @DisplayName("UrlSubstringParser 성공적으로 parse 하는지") + @ParameterizedTest + @CsvSource({ + "https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd, 1", + "https://image.moabam.com/certifications/20231108/5_fwjo39ug-fi2og90-fkw0d, 5" + }) + void url_substring_parser_success(String url, Long result) { + // given, when + String parseUrl = UrlSubstringParser.parseUrl(url, "_"); + + // then + Assertions.assertThat(Long.parseLong(parseUrl)).isEqualTo(result); + } + + @DisplayName("UrlSubstringParser 실패하면 예외 던지는지") + @ParameterizedTest + @CsvSource({ + "https:image.moabam.com.certifications.20231108.1_asdfsdfxcv-4815vcx-asfd", + "https://image.moabam.com/certifications/20231108/5-fwjo39ug-fi2og90-fkw0d", + "https://image.moabam.com/certifications/20231108/5_fwjo39ug-fi2og90-fkw0d/", + "https://image.moabam.com/certifications/20231108/5-fwjo39ug-fi2og90-fkw0d_/" + }) + void url_substring_parser_success(String url) { + // given, when, then + Assertions.assertThatThrownBy(() -> UrlSubstringParser.parseUrl(url, "_")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_REQUEST_URL.getMessage()); + } +} diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java new file mode 100644 index 00000000..24e0c5c4 --- /dev/null +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -0,0 +1,175 @@ +package com.moabam.global.filter; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.web.servlet.HandlerExceptionResolver; + +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.JwtAuthenticationService; +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.filter.AuthorizationFilter; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.error.exception.UnauthorizedException; +import com.moabam.support.fixture.JwtProviderFixture; +import com.moabam.support.fixture.PublicClaimFixture; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; + +@ExtendWith(MockitoExtension.class) +class AuthorizationFilterTest { + + @InjectMocks + AuthorizationFilter authorizationFilter; + + @Mock + HandlerExceptionResolver handlerExceptionResolver; + + @Mock + JwtAuthenticationService jwtAuthenticationService; + + @Mock + AuthorizationService authorizationService; + + @DisplayName("토큰 타입이 Bearer가 아니면 예외 발생") + @ParameterizedTest + @ValueSource(strings = { + "Access", "ID", "Self-signed", "Refresh", "Federated" + }) + void filter_token_type_mismatch(String tokenType) throws ServletException, IOException { + // Given + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + httpServletRequest.addHeader("token_type", tokenType); + MockFilterChain mockFilterChain = new MockFilterChain(); + + // When + Then + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + verify(handlerExceptionResolver, times(1)) + .resolveException( + eq(httpServletRequest), eq(httpServletResponse), + eq(null), any(UnauthorizedException.class)); + } + + @DisplayName("필터가 쿠키가 없다면 예외 발생") + @Test + void filter_have_any_cookie_error() throws ServletException, IOException { + // Given + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockFilterChain mockFilterChain = new MockFilterChain(); + httpServletRequest.addHeader("token_type", "Bearer"); + + // when + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + // then + verify(handlerExceptionResolver, times(1)) + .resolveException( + eq(httpServletRequest), eq(httpServletResponse), + eq(null), any(UnauthorizedException.class)); + } + + @DisplayName("엑세스 토큰이 없어서 예외 발생") + @Test + void filter_have_any_access_token_error() throws ServletException, IOException { + // given + JwtProviderService jwtProviderService = JwtProviderFixture.jwtProviderService(); + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockFilterChain mockFilterChain = new MockFilterChain(); + httpServletRequest.addHeader("token_type", "Bearer"); + + // when + String token = jwtProviderService.provideRefreshToken(Role.USER); + httpServletRequest.setCookies(new Cookie("refresh_token", token)); + + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + // then + verify(handlerExceptionResolver, times(1)) + .resolveException( + eq(httpServletRequest), eq(httpServletResponse), + eq(null), any(UnauthorizedException.class)); + } + + @DisplayName("refresh 토큰이 없어서 예외 발생") + @Test + void filter_have_any_refresh_token_error() throws ServletException, IOException { + // given + JwtProviderService jwtProviderService = JwtProviderFixture.jwtProviderService(); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockFilterChain mockFilterChain = new MockFilterChain(); + + // when + String token = jwtProviderService.provideAccessToken(publicClaim); + httpServletRequest.setCookies( + new Cookie("token_type", "Bearer"), + new Cookie("access_token", token)); + + when(jwtAuthenticationService.parseClaim(token)).thenReturn(publicClaim); + when(jwtAuthenticationService.isTokenExpire(token, Role.USER)).thenReturn(true); + + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + // then + verify(handlerExceptionResolver, times(1)) + .resolveException( + eq(httpServletRequest), eq(httpServletResponse), + eq(null), any(UnauthorizedException.class)); + } + + @DisplayName("새로운 토큰 발급 성공") + @Test + void issue_new_token_success() throws ServletException, IOException { + // given + JwtProviderService jwtProviderService = JwtProviderFixture.jwtProviderService(); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockFilterChain mockFilterChain = new MockFilterChain(); + + // when + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + String refreshToken = jwtProviderService.provideRefreshToken(Role.USER); + httpServletRequest.setCookies( + new Cookie("token_type", "Bearer"), + new Cookie("access_token", accessToken), + new Cookie("refresh_token", refreshToken)); + + when(jwtAuthenticationService.parseClaim(accessToken)).thenReturn(publicClaim); + when(jwtAuthenticationService.isTokenExpire(accessToken, Role.USER)).thenReturn(true); + when(jwtAuthenticationService.isTokenExpire(refreshToken, Role.USER)).thenReturn(false); + + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + // then + verify(authorizationService, times(1)) + .issueServiceToken(httpServletResponse, publicClaim); + + AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); + assertThat(authMember.id()).isEqualTo(1L); + } +} diff --git a/src/test/java/com/moabam/global/filter/PathFilterTest.java b/src/test/java/com/moabam/global/filter/PathFilterTest.java new file mode 100644 index 00000000..9c172127 --- /dev/null +++ b/src/test/java/com/moabam/global/filter/PathFilterTest.java @@ -0,0 +1,77 @@ +package com.moabam.global.filter; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.io.IOException; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +import com.moabam.global.auth.filter.PathFilter; +import com.moabam.global.auth.handler.PathResolver; + +import jakarta.servlet.ServletException; + +@ExtendWith(MockitoExtension.class) +class PathFilterTest { + + @InjectMocks + PathFilter pathFilter; + + @Mock + PathResolver pathResolver; + + @DisplayName("Authentication을 넘기기 위한 필터 설정") + @ParameterizedTest + @ValueSource(strings = { + "GET", "POST", "PATCH", "DELETE", "OPTIONS" + }) + void filter_pass_for_authentication(String method) throws ServletException, IOException { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + httpServletRequest.setMethod(method); + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + willReturn(Optional.of(PathResolver.Path.builder() + .uri("/") + .build())) + .given(pathResolver).permitPathMatch(any()); + + // when + pathFilter.doFilterInternal(httpServletRequest, httpServletResponse, new MockFilterChain()); + + // then + assertThat(httpServletRequest.getAttribute("isPermit")) + .isEqualTo(true); + } + + @DisplayName("경로 허가 없다.") + @Test + void filter_with_no_permit() throws ServletException, IOException { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + willReturn(Optional.empty()) + .given(pathResolver) + .permitPathMatch(any()); + + // when + pathFilter.doFilterInternal(httpServletRequest, httpServletResponse, new MockFilterChain()); + + // then + assertThat(httpServletRequest.getAttribute("isPermit")).isNull(); + } +} diff --git a/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java b/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java new file mode 100644 index 00000000..0f31b471 --- /dev/null +++ b/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java @@ -0,0 +1,22 @@ +package com.moabam.support.annotation; + +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.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.moabam.support.config.TestQuerydslConfig; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Import(TestQuerydslConfig.class) +@DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) +public @interface QuerydslRepositoryTest { + +} diff --git a/src/test/java/com/moabam/support/annotation/WithMember.java b/src/test/java/com/moabam/support/annotation/WithMember.java new file mode 100644 index 00000000..20c4f334 --- /dev/null +++ b/src/test/java/com/moabam/support/annotation/WithMember.java @@ -0,0 +1,19 @@ +package com.moabam.support.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import com.moabam.api.domain.member.Role; + +@Target({ElementType.METHOD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface WithMember { + + long id() default 1L; + + String nickname() default "닉네임"; + + Role role() default Role.USER; +} diff --git a/src/test/java/com/moabam/support/common/ClearDataExtension.java b/src/test/java/com/moabam/support/common/ClearDataExtension.java new file mode 100644 index 00000000..200a2502 --- /dev/null +++ b/src/test/java/com/moabam/support/common/ClearDataExtension.java @@ -0,0 +1,15 @@ +package com.moabam.support.common; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +public class ClearDataExtension implements AfterAllCallback { + + @Override + public void afterAll(ExtensionContext context) { + DataCleanResolver dataCleanResolver = + SpringExtension.getApplicationContext(context).getBean(DataCleanResolver.class); + dataCleanResolver.clean(); + } +} diff --git a/src/test/java/com/moabam/support/common/DataCleanResolver.java b/src/test/java/com/moabam/support/common/DataCleanResolver.java new file mode 100644 index 00000000..dfd259f0 --- /dev/null +++ b/src/test/java/com/moabam/support/common/DataCleanResolver.java @@ -0,0 +1,54 @@ +package com.moabam.support.common; + +import java.util.List; + +import javax.annotation.Nullable; + +import org.springframework.boot.test.context.TestComponent; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.Query; + +@TestComponent +public class DataCleanResolver { + + private EntityManager entityManager; + + public DataCleanResolver(@Nullable EntityManager entityManager) { + this.entityManager = entityManager; + } + + @Transactional + public void clean() { + if (entityManager == null) { + return; + } + + List tableInfos = getTableInfos(); + doClean(tableInfos); + entityManager.clear(); + } + + private List getTableInfos() { + List tableInfos = entityManager.createNativeQuery("show tables").getResultList(); + + return tableInfos.stream() + .map(tableInfo -> (String)tableInfo) + .toList(); + } + + private void doClean(List tableInfos) { + setForeignKeyCheck(0); + tableInfos.stream() + .map(tableInfo -> entityManager.createNativeQuery( + String.format("TRUNCATE TABLE %s", tableInfo))) + .forEach(Query::executeUpdate); + setForeignKeyCheck(1); + } + + private void setForeignKeyCheck(int data) { + entityManager.createNativeQuery(String.format("SET foreign_key_checks = %d", data)) + .executeUpdate(); + } +} diff --git a/src/test/java/com/moabam/support/common/FilterProcessExtension.java b/src/test/java/com/moabam/support/common/FilterProcessExtension.java new file mode 100644 index 00000000..28654921 --- /dev/null +++ b/src/test/java/com/moabam/support/common/FilterProcessExtension.java @@ -0,0 +1,61 @@ +package com.moabam.support.common; + +import static java.util.Objects.*; + +import java.lang.reflect.AnnotatedElement; + +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.ParameterContext; +import org.junit.jupiter.api.extension.ParameterResolutionException; +import org.junit.jupiter.api.extension.ParameterResolver; + +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +import com.moabam.support.annotation.WithMember; + +public class FilterProcessExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { + + @Override + public void beforeEach(ExtensionContext context) { + AnnotatedElement annotatedElement = + context.getElement().orElse(null); + + if (isNull(annotatedElement)) { + return; + } + + WithMember withMember = annotatedElement.getAnnotation(WithMember.class); + + if (isNull(withMember)) { + return; + } + + AuthorizationThreadLocal.setAuthMember( + new AuthMember(withMember.id(), withMember.nickname(), withMember.role())); + } + + @Override + public void afterEach(ExtensionContext context) throws Exception { + AuthorizationThreadLocal.remove(); + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws + ParameterResolutionException { + return parameterContext.getParameter().isAnnotationPresent(WithMember.class); + } + + @Override + public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext) throws + ParameterResolutionException { + WithMember withMember = parameterContext.getParameter().getAnnotation(WithMember.class); + + if (isNull(withMember)) { + return null; + } + + return new AuthMember(withMember.id(), withMember.nickname(), withMember.role()); + } +} diff --git a/src/test/java/com/moabam/support/common/RestDocsFactory.java b/src/test/java/com/moabam/support/common/RestDocsFactory.java new file mode 100644 index 00000000..736f15f9 --- /dev/null +++ b/src/test/java/com/moabam/support/common/RestDocsFactory.java @@ -0,0 +1,31 @@ +package com.moabam.support.common; + +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; + +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.mockmvc.MockMvcRestDocumentation; +import org.springframework.restdocs.mockmvc.MockMvcSnippetConfigurer; +import org.springframework.restdocs.operation.preprocess.OperationRequestPreprocessor; +import org.springframework.restdocs.operation.preprocess.OperationResponsePreprocessor; + +public class RestDocsFactory { + + public static MockMvcSnippetConfigurer restdocs(RestDocumentationContextProvider restDocumentationContextProvider) { + return MockMvcRestDocumentation.documentationConfiguration(restDocumentationContextProvider) + .uris() + .withScheme("http") + .withHost("dev-api.moabam.com") + .withPort(80) + .and() + .snippets() + .withEncoding("UTF-8"); + } + + public static OperationRequestPreprocessor getDocumentRequest() { + return preprocessRequest(prettyPrint()); + } + + public static OperationResponsePreprocessor getDocumentResponse() { + return preprocessResponse(prettyPrint()); + } +} diff --git a/src/test/java/com/moabam/support/common/TestClockHolder.java b/src/test/java/com/moabam/support/common/TestClockHolder.java new file mode 100644 index 00000000..aa75977d --- /dev/null +++ b/src/test/java/com/moabam/support/common/TestClockHolder.java @@ -0,0 +1,24 @@ +package com.moabam.support.common; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; + +import com.moabam.global.common.util.ClockHolder; + +@Component +@Profile("test") +public class TestClockHolder implements ClockHolder { + + @Override + public LocalDateTime times() { + return LocalDateTime.of(2023, 12, 3, 14, 30, 0); + } + + @Override + public LocalDate date() { + return LocalDateTime.now().toLocalDate(); + } +} diff --git a/src/test/java/com/moabam/support/common/WithFilterSupporter.java b/src/test/java/com/moabam/support/common/WithFilterSupporter.java new file mode 100644 index 00000000..cd92c8cf --- /dev/null +++ b/src/test/java/com/moabam/support/common/WithFilterSupporter.java @@ -0,0 +1,55 @@ +package com.moabam.support.common; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.restdocs.RestDocumentationContextProvider; +import org.springframework.restdocs.RestDocumentationExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.api.domain.member.Role; +import com.moabam.global.common.util.cookie.CookieUtils; +import com.moabam.global.config.TokenConfig; +import com.moabam.support.fixture.PublicClaimFixture; + +@SpringBootTest +public class WithFilterSupporter { + + @RegisterExtension + RestDocumentationExtension restDocumentationExtension = new RestDocumentationExtension(); + + @Autowired + WebApplicationContext webApplicationContext; + + @Autowired + JwtProviderService jwtProviderService; + + @Autowired + TokenConfig tokenConfig; + + @Autowired + CookieUtils cookieUtils; + + protected MockMvc mockMvc; + + @BeforeEach + void setUpMockMvc(RestDocumentationContextProvider contextProvider) { + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) + .apply(RestDocsFactory.restdocs(contextProvider)) + .defaultRequest(get("/") + .cookie(cookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())) + .cookie(cookieUtils.tokenCookie("access_token", + jwtProviderService.provideAccessToken(PublicClaimFixture.publicClaim()), + tokenConfig.getRefreshExpire())) + .cookie(cookieUtils.tokenCookie("refresh_token", + jwtProviderService.provideRefreshToken(Role.USER), + tokenConfig.getRefreshExpire()))) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java new file mode 100644 index 00000000..ea3eecad --- /dev/null +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -0,0 +1,47 @@ +package com.moabam.support.common; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.context.annotation.Import; +import org.springframework.web.servlet.mvc.support.DefaultHandlerExceptionResolver; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.filter.CorsFilter; +import com.moabam.global.auth.handler.PathResolver; +import com.moabam.global.config.AllowOriginConfig; + +@Import(DataCleanResolver.class) +@ExtendWith({FilterProcessExtension.class, ClearDataExtension.class}) +public class WithoutFilterSupporter { + + @MockBean + private PathResolver pathResolver; + + @MockBean + private DefaultHandlerExceptionResolver handlerExceptionResolver; + + @SpyBean + private CorsFilter corsFilter; + + @MockBean + private AllowOriginConfig allowOriginConfig; + + @BeforeEach + void setUpMock() { + willReturn("http://localhost:8080") + .given(corsFilter).getReferer(any()); + + willReturn(Optional.of(PathResolver.Path.builder() + .uri("/") + .role(Role.USER) + .build())) + .given(pathResolver).permitPathMatch(any()); + } +} diff --git a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java new file mode 100644 index 00000000..c494ab42 --- /dev/null +++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java @@ -0,0 +1,53 @@ +package com.moabam.support.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import com.moabam.api.domain.coupon.repository.CouponWalletSearchRepository; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.item.repository.ItemSearchRepository; +import com.moabam.api.domain.member.repository.MemberSearchRepository; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + +@EnableJpaAuditing +@TestConfiguration +public class TestQuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } + + @Bean + public ItemSearchRepository itemSearchRepository() { + return new ItemSearchRepository(jpaQueryFactory()); + } + + @Bean + public InventorySearchRepository inventorySearchRepository() { + return new InventorySearchRepository(jpaQueryFactory()); + } + + @Bean + public CertificationsSearchRepository certificationsSearchRepository() { + return new CertificationsSearchRepository(jpaQueryFactory()); + } + + @Bean + public MemberSearchRepository memberSearchRepository() { + return new MemberSearchRepository(jpaQueryFactory()); + } + + @Bean + public CouponWalletSearchRepository couponWalletSearchRepository() { + return new CouponWalletSearchRepository(jpaQueryFactory()); + } +} diff --git a/src/test/java/com/moabam/support/fixture/AuthMemberFixture.java b/src/test/java/com/moabam/support/fixture/AuthMemberFixture.java new file mode 100644 index 00000000..e4a899b2 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/AuthMemberFixture.java @@ -0,0 +1,11 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.member.Member; +import com.moabam.global.auth.model.AuthMember; + +public final class AuthMemberFixture { + + public static AuthMember authMember(Member member) { + return new AuthMember(member.getId(), member.getNickname(), member.getRole()); + } +} diff --git a/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java new file mode 100644 index 00000000..c8e00e7f --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java @@ -0,0 +1,29 @@ +package com.moabam.support.fixture; + +import com.moabam.api.dto.auth.AuthorizationCodeResponse; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.AuthorizationTokenResponse; + +public final class AuthorizationResponseFixture { + + static final String tokenType = "tokenType"; + static final String accessToken = "accessToken"; + static final String idToken = "id"; + static final String expiresin = "exp"; + static final String refreshToken = "ref"; + static final String refreshTokenExpiresIn = "refs"; + static final String scope = "scope"; + + public static AuthorizationCodeResponse successCodeResponse() { + return new AuthorizationCodeResponse("test", null, null, null); + } + + public static AuthorizationTokenInfoResponse authorizationTokenInfoResponse() { + return new AuthorizationTokenInfoResponse(1L, "expiresIn", "appId"); + } + + public static AuthorizationTokenResponse authorizationTokenResponse() { + return new AuthorizationTokenResponse(tokenType, accessToken, idToken, + expiresin, refreshToken, refreshTokenExpiresIn, scope); + } +} diff --git a/src/test/java/com/moabam/support/fixture/BadgeFixture.java b/src/test/java/com/moabam/support/fixture/BadgeFixture.java new file mode 100644 index 00000000..de7d40b1 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/BadgeFixture.java @@ -0,0 +1,14 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.member.Badge; +import com.moabam.api.domain.member.BadgeType; + +public class BadgeFixture { + + public static Badge badge(Long memberId, BadgeType badgeType) { + return Badge.builder() + .memberId(memberId) + .type(badgeType) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/BugFixture.java b/src/test/java/com/moabam/support/fixture/BugFixture.java new file mode 100644 index 00000000..ec3b82c9 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/BugFixture.java @@ -0,0 +1,52 @@ +package com.moabam.support.fixture; + +import static com.moabam.support.fixture.ProductFixture.*; + +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugHistory; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.payment.Payment; + +public final class BugFixture { + + public static final int MORNING_BUG = 10; + public static final int NIGHT_BUG = 20; + public static final int GOLDEN_BUG = 30; + public static final int REWARD_MORNING_BUG = 3; + + public static Bug bug() { + return Bug.builder() + .morningBug(MORNING_BUG) + .nightBug(NIGHT_BUG) + .goldenBug(GOLDEN_BUG) + .build(); + } + + public static Bug zeroBug() { + return Bug.builder() + .morningBug(0) + .nightBug(0) + .goldenBug(0) + .build(); + } + + public static BugHistory rewardMorningBugHistory(Long memberId) { + return BugHistory.builder() + .memberId(memberId) + .bugType(BugType.MORNING) + .actionType(BugActionType.REWARD) + .quantity(REWARD_MORNING_BUG) + .build(); + } + + public static BugHistory chargeGoldenBugHistory(Long memberId, Payment payment) { + return BugHistory.builder() + .memberId(memberId) + .payment(payment) + .bugType(BugType.GOLDEN) + .actionType(BugActionType.CHARGE) + .quantity(BUG_PRODUCT_QUANTITY) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/BugHistoryFixture.java b/src/test/java/com/moabam/support/fixture/BugHistoryFixture.java new file mode 100644 index 00000000..544fcce4 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/BugHistoryFixture.java @@ -0,0 +1,26 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugHistory; +import com.moabam.api.domain.bug.BugType; + +public final class BugHistoryFixture { + + public static BugHistory rewardMorningBug(Long memberId, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(BugType.MORNING) + .actionType(BugActionType.REWARD) + .quantity(quantity) + .build(); + } + + public static BugHistory rewardNightBug(Long memberId, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(BugType.NIGHT) + .actionType(BugActionType.REWARD) + .quantity(quantity) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java new file mode 100644 index 00000000..6cff038a --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -0,0 +1,184 @@ +package com.moabam.support.fixture; + +import java.time.LocalDate; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Stream; + +import org.junit.jupiter.params.provider.Arguments; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.dto.coupon.CouponStatusRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; + +public final class CouponFixture { + + public static final String DISCOUNT_1000_COUPON_NAME = "황금벌레 1000원 할인"; + public static final String DISCOUNT_10000_COUPON_NAME = "황금벌레 10000원 할인"; + + public static Coupon coupon() { + return Coupon.builder() + .name("couponName") + .point(1000) + .type(CouponType.MORNING) + .maxCount(100) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + + public static Coupon coupon(String name, int startAt) { + return Coupon.builder() + .name(name) + .point(10) + .type(CouponType.MORNING) + .maxCount(100) + .startAt(LocalDate.of(2023, startAt, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + + public static Coupon coupon(int point, int maxCount) { + return Coupon.builder() + .name("couponName") + .point(point) + .type(CouponType.MORNING) + .maxCount(maxCount) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + + public static Coupon coupon(CouponType couponType, int point) { + return Coupon.builder() + .name("couponName") + .point(point) + .type(couponType) + .maxCount(100) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + + public static Coupon coupon(String name, int startMonth, int openMonth) { + return Coupon.builder() + .name(name) + .point(10) + .type(CouponType.MORNING) + .maxCount(100) + .startAt(LocalDate.of(2023, startMonth, 1)) + .openAt(LocalDate.of(2023, openMonth, 1)) + .adminId(1L) + .build(); + } + + public static Coupon discount1000Coupon() { + return Coupon.builder() + .name(DISCOUNT_1000_COUPON_NAME) + .point(1000) + .type(CouponType.DISCOUNT) + .maxCount(100) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + + public static Coupon discount10000Coupon() { + return Coupon.builder() + .name(DISCOUNT_10000_COUPON_NAME) + .point(10000) + .type(CouponType.DISCOUNT) + .maxCount(100) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 2, 1)) + .adminId(1L) + .build(); + } + + public static CreateCouponRequest createCouponRequest() { + return CreateCouponRequest.builder() + .name("couponName") + .description("coupon description") + .point(10) + .type(CouponType.GOLDEN.getName()) + .maxCount(10) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .build(); + } + + public static CreateCouponRequest createCouponRequest(String couponType, int startMonth, int openMonth) { + return CreateCouponRequest.builder() + .name("couponName") + .description("coupon description") + .point(10) + .type(couponType) + .maxCount(10) + .startAt(LocalDate.of(2023, startMonth, 1)) + .openAt(LocalDate.of(2023, openMonth, 1)) + .build(); + } + + public static CouponStatusRequest couponStatusRequest(boolean ongoing, boolean ended) { + return CouponStatusRequest.builder() + .opened(ongoing) + .ended(ended) + .build(); + } + + public static Stream provideCoupons() { + return Stream.of(Arguments.of( + List.of( + coupon("coupon1", 3, 1), + coupon("coupon2", 4, 2), + coupon("coupon3", 5, 3), + coupon("coupon4", 6, 4), + coupon("coupon5", 7, 5), + coupon("coupon6", 8, 6), + coupon("coupon7", 9, 7), + coupon("coupon8", 10, 8), + coupon("coupon9", 11, 9), + coupon("coupon10", 12, 10) + )) + ); + } + + public static Stream provideValues_Object() { + Set values = new HashSet<>(); + values.add(2L); + values.add(3L); + values.add(4L); + values.add(1L); + values.add(5L); + values.add(6L); + values.add(7L); + values.add(8L); + values.add(9L); + values.add(10L); + + return Stream.of(Arguments.of(values)); + } + + public static Stream provideValues_Long() { + Set values = new HashSet<>(); + values.add(2L); + values.add(3L); + values.add(4L); + values.add(1L); + values.add(5L); + values.add(6L); + values.add(7L); + values.add(8L); + values.add(9L); + values.add(10L); + + return Stream.of(Arguments.of(values)); + } +} diff --git a/src/test/java/com/moabam/support/fixture/CouponWalletFixture.java b/src/test/java/com/moabam/support/fixture/CouponWalletFixture.java new file mode 100644 index 00000000..7fa78e1f --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/CouponWalletFixture.java @@ -0,0 +1,38 @@ +package com.moabam.support.fixture; + +import static com.moabam.support.fixture.CouponFixture.*; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.params.provider.Arguments; + +import com.moabam.api.domain.coupon.CouponWallet; + +public final class CouponWalletFixture { + + public static Stream provideCouponWalletByCouponId1_total5() { + return Stream.of(Arguments.of( + List.of( + CouponWallet.create(1L, coupon("c1", 1)), + CouponWallet.create(1L, coupon("c2", 2)), + CouponWallet.create(1L, coupon("c3", 3)), + CouponWallet.create(1L, coupon("c4", 4)), + CouponWallet.create(1L, coupon("c5", 5)) + )) + ); + } + + public static Stream provideCouponWalletAll() { + return Stream.of(Arguments.of( + List.of( + CouponWallet.create(1L, coupon("c2", 2)), + CouponWallet.create(2L, coupon("c3", 3)), + CouponWallet.create(2L, coupon("c4", 4)), + CouponWallet.create(3L, coupon("c5", 5)), + CouponWallet.create(3L, coupon("c6", 6)), + CouponWallet.create(3L, coupon("c7", 7)) + )) + ); + } +} diff --git a/src/test/java/com/moabam/support/fixture/DeleteMemberFixture.java b/src/test/java/com/moabam/support/fixture/DeleteMemberFixture.java new file mode 100644 index 00000000..5da3cd6d --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/DeleteMemberFixture.java @@ -0,0 +1,13 @@ +package com.moabam.support.fixture; + +import com.moabam.api.dto.member.DeleteMemberResponse; + +public final class DeleteMemberFixture { + + public static DeleteMemberResponse deleteMemberResponse() { + return DeleteMemberResponse.builder() + .id(1L) + .socialId("1") + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/InventoryFixture.java b/src/test/java/com/moabam/support/fixture/InventoryFixture.java new file mode 100644 index 00000000..c1be1228 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/InventoryFixture.java @@ -0,0 +1,14 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; + +public class InventoryFixture { + + public static Inventory inventory(Long memberId, Item item) { + return Inventory.builder() + .memberId(memberId) + .item(item) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/ItemFixture.java b/src/test/java/com/moabam/support/fixture/ItemFixture.java new file mode 100644 index 00000000..3e65477a --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ItemFixture.java @@ -0,0 +1,58 @@ +package com.moabam.support.fixture; + +import static com.moabam.global.common.util.BaseImageUrl.*; + +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemCategory; +import com.moabam.api.domain.item.ItemImage; +import com.moabam.api.domain.item.ItemType; + +public class ItemFixture { + + public static final String MORNING_SANTA_SKIN_NAME = "산타 오목눈이"; + public static final String MORNING_SANTA_SKIN_IMAGE = IMAGE_DOMAIN + "item/morning_santa.png"; + public static final String MORNING_KILLER_SKIN_NAME = "킬러 오목눈이"; + public static final String MORNING_KILLER_SKIN_IMAGE = IMAGE_DOMAIN + "item/morning_killer.png"; + public static final String NIGHT_MAGE_SKIN_NAME = "메이지 부엉이"; + public static final String NIGHT_MAGE_SKIN_IMAGE = IMAGE_DOMAIN + "item/night_mage.png"; + + public static Item.ItemBuilder morningSantaSkin() { + ItemImage image = ItemImage.builder() + .awakeImage(MORNING_SANTA_SKIN_IMAGE) + .sleepImage(MORNING_SANTA_SKIN_IMAGE) + .build(); + + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_SANTA_SKIN_NAME) + .image(image); + } + + public static Item.ItemBuilder morningKillerSkin() { + ItemImage image = ItemImage.builder() + .awakeImage(MORNING_KILLER_SKIN_IMAGE) + .sleepImage(MORNING_KILLER_SKIN_IMAGE) + .build(); + + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_KILLER_SKIN_NAME) + .image(image); + } + + public static Item nightMageSkin() { + ItemImage image = ItemImage.builder() + .awakeImage(NIGHT_MAGE_SKIN_IMAGE) + .sleepImage(NIGHT_MAGE_SKIN_IMAGE) + .build(); + + return Item.builder() + .type(ItemType.NIGHT) + .category(ItemCategory.SKIN) + .name(NIGHT_MAGE_SKIN_NAME) + .image(image) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java new file mode 100644 index 00000000..50fa714c --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java @@ -0,0 +1,21 @@ +package com.moabam.support.fixture; + +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.global.config.TokenConfig; + +public class JwtProviderFixture { + + public static final String originIss = "PARK"; + public static final String originSecretKey = "testestestestestestestestestesttestestestestestestestestestest"; + public static final String adminKey = "testestestestestestestestestesttestestestestestestestestestest"; + public static final long originId = 1L; + public static final long originAccessExpire = 100000; + public static final long originRefreshExpire = 150000; + + public static JwtProviderService jwtProviderService() { + TokenConfig tokenConfig = + new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey, adminKey); + + return new JwtProviderService(tokenConfig); + } +} diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java new file mode 100644 index 00000000..c0ed7551 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -0,0 +1,39 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.member.Member; + +public final class MemberFixture { + + public static final String SOCIAL_ID = "1"; + public static final String NICKNAME = "모아밤"; + + public static Member member() { + return Member.builder() + .socialId(SOCIAL_ID) + .bug(BugFixture.bug()) + .build(); + } + + public static Member member(Long id) { + return Member.builder() + .id(id) + .socialId(SOCIAL_ID) + .bug(BugFixture.bug()) + .build(); + } + + public static Member member(Bug bug) { + return Member.builder() + .socialId(SOCIAL_ID) + .bug(bug) + .build(); + } + + public static Member member(String socialId) { + return Member.builder() + .socialId(socialId) + .bug(BugFixture.bug()) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java new file mode 100644 index 00000000..6f43c132 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java @@ -0,0 +1,40 @@ +package com.moabam.support.fixture; + +import static com.moabam.global.common.util.BaseImageUrl.*; + +import java.util.List; + +import com.moabam.api.domain.member.BadgeType; +import com.moabam.api.dto.member.MemberInfo; + +public class MemberInfoSearchFixture { + + private static final String NICKNAME = "nickname"; + private static final String PROFILE_IMAGE = IMAGE_DOMAIN + MEMBER_PROFILE_URL; + private static final String INTRO = "intro"; + private static final long TOTAL_CERTIFY_COUNT = 15; + private static final String MORNING_EGG = IMAGE_DOMAIN + DEFAULT_MORNING_EGG_URL; + private static final String NIGHT_EGG = IMAGE_DOMAIN + DEFAULT_NIGHT_EGG_URL; + + public static List friendMemberInfo() { + return friendMemberInfo(TOTAL_CERTIFY_COUNT); + } + + public static List friendMemberInfo(long total) { + return List.of( + new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.BIRTH, + 0, 0, 0), + new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.LEVEL10, + 0, 0, 0) + ); + } + + public static List myInfo(String morningImage, String nightImage) { + return List.of( + new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, + BadgeType.BIRTH, 0, 0, 0), + new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, + BadgeType.LEVEL10, 0, 0, 0) + ); + } +} diff --git a/src/test/java/com/moabam/support/fixture/ModifyImageFixture.java b/src/test/java/com/moabam/support/fixture/ModifyImageFixture.java new file mode 100644 index 00000000..afee62fd --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ModifyImageFixture.java @@ -0,0 +1,28 @@ +package com.moabam.support.fixture; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; + +import org.springframework.mock.web.MockMultipartFile; + +import com.moabam.api.dto.member.ModifyMemberRequest; + +public class ModifyImageFixture { + + public static MockMultipartFile makeMultipartFile() { + try { + File file = new File("src/test/resources/image.png"); + FileInputStream fileInputStream = new FileInputStream(file); + + return new MockMultipartFile("1", "image.png", "image/png", fileInputStream); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + public static ModifyMemberRequest modifyMemberRequest() { + return new ModifyMemberRequest("intro", "sldsldsld"); + } + +} diff --git a/src/test/java/com/moabam/support/fixture/ParticipantFixture.java b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java new file mode 100644 index 00000000..e8d60215 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java @@ -0,0 +1,44 @@ +package com.moabam.support.fixture; + +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.params.provider.Arguments; + +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; + +public final class ParticipantFixture { + + public static Participant participant(Room room, Long memberId) { + return Participant.builder() + .room(room) + .memberId(memberId) + .build(); + } + + public static Stream provideParticipants() { + Room room = RoomFixture.room(10); + + return Stream.of(Arguments.of(List.of( + ParticipantFixture.participant(room, 1L), + ParticipantFixture.participant(room, 3L), + ParticipantFixture.participant(room, 7L) + ))); + } + + public static Stream provideRoomAndParticipants() { + Room room = RoomFixture.room(10); + + return Stream.of(Arguments.of( + room, + List.of( + ParticipantFixture.participant(room, 1L), + ParticipantFixture.participant(room, 2L), + ParticipantFixture.participant(room, 3L), + ParticipantFixture.participant(room, 5L), + ParticipantFixture.participant(room, 7L) + )) + ); + } +} diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java new file mode 100644 index 00000000..464652fb --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -0,0 +1,60 @@ +package com.moabam.support.fixture; + +import static com.moabam.support.fixture.ProductFixture.*; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.payment.Order; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; + +public final class PaymentFixture { + + public static final String PAYMENT_KEY = "payment_key_123"; + public static final String ORDER_ID = "random_order_id_123"; + public static final int AMOUNT = 3000; + + public static Payment payment(Product product) { + return Payment.builder() + .memberId(1L) + .product(product) + .order(order(product)) + .totalAmount(product.getPrice()) + .build(); + } + + public static Payment paymentWithCoupon(Product product, Coupon coupon, Long couponWalletId) { + return Payment.builder() + .memberId(1L) + .product(product) + .couponWalletId(couponWalletId) + .order(order(product)) + .totalAmount(product.getPrice()) + .discountAmount(coupon.getPoint()) + .build(); + } + + public static Order order(Product product) { + return Order.builder() + .name(product.getName()) + .build(); + } + + public static ConfirmPaymentRequest confirmPaymentRequest() { + return ConfirmPaymentRequest.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .amount(AMOUNT) + .build(); + } + + public static ConfirmTossPaymentResponse confirmTossPaymentResponse() { + return ConfirmTossPaymentResponse.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .orderName(BUG_PRODUCT_NAME) + .totalAmount(AMOUNT) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/ProductFixture.java b/src/test/java/com/moabam/support/fixture/ProductFixture.java new file mode 100644 index 00000000..73cb81cd --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ProductFixture.java @@ -0,0 +1,20 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.ProductType; + +public class ProductFixture { + + public static final String BUG_PRODUCT_NAME = "황금벌레 10"; + public static final int BUG_PRODUCT_PRICE = 3000; + public static final int BUG_PRODUCT_QUANTITY = 10; + + public static Product bugProduct() { + return Product.builder() + .type(ProductType.BUG) + .name(BUG_PRODUCT_NAME) + .price(BUG_PRODUCT_PRICE) + .quantity(BUG_PRODUCT_QUANTITY) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java b/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java new file mode 100644 index 00000000..06bbb415 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java @@ -0,0 +1,15 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; + +public class PublicClaimFixture { + + public static final PublicClaim publicClaim() { + return PublicClaim.builder() + .id(1L) + .nickname("nickname") + .role(Role.USER) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/RankingInfoFixture.java b/src/test/java/com/moabam/support/fixture/RankingInfoFixture.java new file mode 100644 index 00000000..9058ebcc --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/RankingInfoFixture.java @@ -0,0 +1,5 @@ +package com.moabam.support.fixture; + +public class RankingInfoFixture { + +} diff --git a/src/test/java/com/moabam/support/fixture/ReportFixture.java b/src/test/java/com/moabam/support/fixture/ReportFixture.java new file mode 100644 index 00000000..a3d48301 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ReportFixture.java @@ -0,0 +1,30 @@ +package com.moabam.support.fixture; + +import com.moabam.api.domain.report.Report; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.api.dto.report.ReportRequest; + +public class ReportFixture { + + private static Long reportedId = 99L; + private static Long roomId = 1L; + private static Long certificationId = 1L; + + public static Report report(Room room, Certification certification) { + return Report.builder() + .reporterId(1L) + .reportedMemberId(2L) + .room(room) + .certification(certification) + .build(); + } + + public static ReportRequest reportRequest() { + return new ReportRequest(reportedId, roomId, certificationId, "description"); + } + + public static ReportRequest reportRequest(Long reportedId, Long roomId, Long certificationId) { + return new ReportRequest(reportedId, roomId, certificationId, "description"); + } +} diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java new file mode 100644 index 00000000..b286c880 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -0,0 +1,170 @@ +package com.moabam.support.fixture; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.mock.web.MockMultipartFile; + +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.DailyMemberCertification; +import com.moabam.api.domain.room.DailyRoomCertification; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; + +public class RoomFixture { + + public static Room room() { + return Room.builder() + .title("testTitle") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(8) + .build(); + } + + public static Room room(int certifyTime) { + return Room.builder() + .id(1L) + .title("testTitle") + .roomType(RoomType.MORNING) + .certifyTime(certifyTime) + .maxUserCount(8) + .build(); + } + + public static Room room(String title, RoomType roomType, int certifyTime) { + return Room.builder() + .title(title) + .roomType(roomType) + .certifyTime(certifyTime) + .maxUserCount(8) + .build(); + } + + public static Room room(String title, RoomType roomType, int certifyTime, String password) { + return Room.builder() + .title(title) + .password(password) + .roomType(roomType) + .certifyTime(certifyTime) + .maxUserCount(8) + .build(); + } + + public static Participant participant(Room room, Long memberId) { + return Participant.builder() + .room(room) + .memberId(memberId) + .build(); + } + + public static Routine routine(Room room, String content) { + return Routine.builder() + .room(room) + .content(content) + .build(); + } + + public static List routines(Room room) { + List routines = new ArrayList<>(); + + Routine routine1 = Routine.builder() + .room(room) + .content("첫 루틴") + .build(); + Routine routine2 = Routine.builder() + .room(room) + .content("두번째 루틴") + .build(); + + routines.add(routine1); + routines.add(routine2); + + return routines; + } + + public static Certification certification(Routine routine) { + return Certification.builder() + .routine(routine) + .memberId(1L) + .image("test1") + .build(); + } + + public static DailyMemberCertification dailyMemberCertification(Long memberId, Long roomId, + Participant participant) { + return DailyMemberCertification.builder() + .memberId(memberId) + .roomId(roomId) + .participant(participant) + .build(); + } + + public static List dailyMemberCertifications(Long roomId, Participant participant) { + + List dailyMemberCertifications = new ArrayList<>(); + dailyMemberCertifications.add(DailyMemberCertification.builder() + .roomId(roomId) + .memberId(1L) + .participant(participant) + .build()); + dailyMemberCertifications.add(DailyMemberCertification.builder() + .roomId(roomId) + .memberId(2L) + .participant(participant) + .build()); + dailyMemberCertifications.add(DailyMemberCertification.builder() + .roomId(roomId) + .memberId(3L) + .participant(participant) + .build()); + + return dailyMemberCertifications; + } + + public static DailyRoomCertification dailyRoomCertification(Long roomId, LocalDate today) { + return DailyRoomCertification.builder() + .roomId(roomId) + .certifiedAt(today) + .build(); + } + + public static MockMultipartFile makeMultipartFile1() { + try { + File file = new File("src/test/resources/image.png"); + FileInputStream fileInputStream = new FileInputStream(file); + + return new MockMultipartFile("1", "image.png", "image/png", fileInputStream); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + public static MockMultipartFile makeMultipartFile2() { + try { + File file = new File("src/test/resources/image.png"); + FileInputStream fileInputStream = new FileInputStream(file); + + return new MockMultipartFile("2", "image.png", "image/png", fileInputStream); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + public static MockMultipartFile makeMultipartFile3() { + try { + File file = new File("src/test/resources/image.png"); + FileInputStream fileInputStream = new FileInputStream(file); + + return new MockMultipartFile("3", "image.png", "image/png", fileInputStream); + } catch (final IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/com/moabam/support/fixture/TokenSaveValueFixture.java b/src/test/java/com/moabam/support/fixture/TokenSaveValueFixture.java new file mode 100644 index 00000000..efc64305 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/TokenSaveValueFixture.java @@ -0,0 +1,24 @@ +package com.moabam.support.fixture; + +import com.moabam.api.dto.auth.TokenSaveValue; + +public class TokenSaveValueFixture { + + public static TokenSaveValue tokenSaveValue(String token, String ip) { + return TokenSaveValue.builder() + .refreshToken(token) + .loginIp(ip) + .build(); + } + + public static TokenSaveValue tokenSaveValue(String token) { + return TokenSaveValue.builder() + .refreshToken(token) + .loginIp("127.0.0.1") + .build(); + } + + public static TokenSaveValue tokenSaveValue() { + return tokenSaveValue("token"); + } +} diff --git a/src/test/java/com/moabam/support/snippet/CouponSnippet.java b/src/test/java/com/moabam/support/snippet/CouponSnippet.java new file mode 100644 index 00000000..852c6f4c --- /dev/null +++ b/src/test/java/com/moabam/support/snippet/CouponSnippet.java @@ -0,0 +1,50 @@ +package com.moabam.support.snippet; + +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; + +import org.springframework.restdocs.payload.RequestFieldsSnippet; +import org.springframework.restdocs.payload.ResponseFieldsSnippet; +import org.springframework.restdocs.snippet.Snippet; + +public final class CouponSnippet { + + public static final RequestFieldsSnippet CREATE_COUPON_REQUEST = requestFields( + fieldWithPath("name").type(STRING).description("쿠폰명"), + fieldWithPath("description").type(STRING).description("쿠폰 간단 소개 (NULL 가능)"), + fieldWithPath("type").type(STRING).description("쿠폰 종류 (아침, 저녁, 황금, 할인)"), + fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("maxCount").type(NUMBER).description("쿠폰을 발급 최대 갯수"), + fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd)"), + fieldWithPath("openAt").type(STRING).description("쿠폰 정보 오픈 날짜 (Ex: yyyy-MM-dd)") + ); + + public static final ResponseFieldsSnippet COUPON_RESPONSE = responseFields( + fieldWithPath("id").type(NUMBER).description("쿠폰 ID"), + fieldWithPath("adminId").type(NUMBER).description("쿠폰 관리자 ID"), + fieldWithPath("name").type(STRING).description("쿠폰명"), + fieldWithPath("description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), + fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("maxCount").type(NUMBER).description("쿠폰을 발급 최대 갯수"), + fieldWithPath("type").type(STRING).description("쿠폰 종류 (MORNING, NIGHT, GOLDEN, DISCOUNT)"), + fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd)"), + fieldWithPath("openAt").type(STRING).description("쿠폰 정보 오픈 날짜 (Ex: yyyy-MM-dd)") + ); + + public static final Snippet COUPON_STATUS_REQUEST = requestFields( + fieldWithPath("opened").type(BOOLEAN).description("쿠폰 정보가 오픈된 쿠폰 (true, false)"), + fieldWithPath("ended").type(BOOLEAN).description("종료된 쿠폰 (true, false)") + ); + + public static final ResponseFieldsSnippet COUPON_STATUS_RESPONSE = responseFields( + fieldWithPath("[].id").type(NUMBER).description("쿠폰 ID"), + fieldWithPath("[].adminId").type(NUMBER).description("쿠폰 관리자 ID"), + fieldWithPath("[].name").type(STRING).description("쿠폰명"), + fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), + fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("[].maxCount").type(NUMBER).description("쿠폰을 발급 최대 갯수"), + fieldWithPath("[].type").type(STRING).description("쿠폰 종류 (MORNING, NIGHT, GOLDEN, DISCOUNT)"), + fieldWithPath("[].startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd)"), + fieldWithPath("[].openAt").type(STRING).description("쿠폰 정보 오픈 날짜 (Ex: yyyy-MM-dd)") + ); +} diff --git a/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java b/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java new file mode 100644 index 00000000..d4a887cb --- /dev/null +++ b/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java @@ -0,0 +1,18 @@ +package com.moabam.support.snippet; + +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; + +import org.springframework.restdocs.payload.ResponseFieldsSnippet; + +public final class CouponWalletSnippet { + + public static final ResponseFieldsSnippet COUPON_WALLET_RESPONSE = responseFields( + fieldWithPath("[].walletId").type(NUMBER).description("쿠폰지갑 ID"), + fieldWithPath("[].id").type(NUMBER).description("쿠폰 ID"), + fieldWithPath("[].name").type(STRING).description("쿠폰명"), + fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), + fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("[].type").type(STRING).description("쿠폰 종류 (MORNING, NIGHT, GOLDEN, DISCOUNT)") + ); +} diff --git a/src/test/java/com/moabam/support/snippet/ErrorSnippet.java b/src/test/java/com/moabam/support/snippet/ErrorSnippet.java new file mode 100644 index 00000000..69e2c5d3 --- /dev/null +++ b/src/test/java/com/moabam/support/snippet/ErrorSnippet.java @@ -0,0 +1,13 @@ +package com.moabam.support.snippet; + +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; + +import org.springframework.restdocs.snippet.Snippet; + +public class ErrorSnippet { + + public static final Snippet ERROR_MESSAGE_RESPONSE = responseFields( + fieldWithPath("message").type(STRING).description("에러 메시지") + ); +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml new file mode 100644 index 00000000..7083d3d7 --- /dev/null +++ b/src/test/resources/application.yml @@ -0,0 +1,93 @@ +logging: + level: + org.hibernate.SQL: debug + org.springframework: DEBUG + +spring: + + # Profile + profiles: + active: test + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://localhost:3305/moabam?serverTimezone=UTC&characterEncoding=UTF-8 + username: root + password: 1234 + + jpa: + hibernate: + ddl-auto: create + properties: + hibernate: + format_sql: true + highlight_sql: true + database: mysql + + # Redis + data: + redis: + host: 127.0.0.1 + port: 6379 + + # AWS + cloud: + aws: + region: + static: ap-test-test + s3: + bucket: test + url: test + cloud-front: + url: test + credentials: + access-key: test + secret-key: test + max-request-size: 10MB # 요청 당 최대 사이즈 + +oauth2: + client: + provider: test + client-id: testtestetsttest + client-secret: testtestetsttest + authorization-grant-type: authorization_code + admin-key: testtesttesttesttesttesttest + scope: + - profile_nickname + - profile_image + + provider: + authorization_uri: https://authorization.com/test/test + redirect_uri: http://redirect:8080/test + token-uri: https://kauth.kakao.com/oauth/token + token-info: https://kapi.kakao.com/v1/user/access_token_info + unlink: https://kapi.kakao.com/v1/user/unlink + admin-redirect-uri: https://dev-admin.moabam.com/login/kakao/oauth + + +token: + iss: "PARK" + access-expire: 100000 + refresh-expire: 150000 + secret-key: testestestestestestestestestesttestestestestestestestestestest + admin-secret: testestestestestestestestestesttestestestestestestestestestest + +allows: + admin-domain: "localhost" + domain: "localhost" + origin: + - "https://test.com" + - "https://test.com" + +admin: moamoamoabam + +# Payment +payment: + toss: + base-url: "https://api.tosspayments.com" + secret-key: "test_sk_4yKeq5bgrpWk4XYdDoBxVGX0lzW6:" + +# Webhook +webhook: + slack: + url: test diff --git a/src/test/resources/image.png b/src/test/resources/image.png new file mode 100755 index 00000000..0950368c Binary files /dev/null and b/src/test/resources/image.png differ