From a40357d4a78d92ebafaa77ba5f355e8ecc688633 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 27 Oct 2023 15:54:52 +0900 Subject: [PATCH 001/185] =?UTF-8?q?chore:=20sonarcloud=20=EB=B0=8F=20jacoc?= =?UTF-8?q?o=20=EC=84=B8=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 46 ++++++++++++++++++++++++++++++++++++++++ build.gradle | 23 ++++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3109497d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,46 @@ +name: ci + +on: + 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 }} diff --git a/build.gradle b/build.gradle index 34b36b11..fb1017b4 100644 --- a/build.gradle +++ b/build.gradle @@ -2,6 +2,8 @@ plugins { id 'java' id 'org.springframework.boot' version '3.1.5' id 'io.spring.dependency-management' version '1.1.3' + id 'org.sonarqube' version '4.4.1.3373' + id 'jacoco' } group = 'com.moabam' @@ -46,8 +48,29 @@ dependencies { annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta' annotationProcessor 'jakarta.annotation:jakarta.annotation-api' annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + + // H2 + implementation 'com.h2database:h2' } tasks.named('test') { useJUnitPlatform() + finalizedBy jacocoTestReport +} + +jacocoTestReport { + dependsOn test + reports { + xml.required = true + html.required = true + } +} + +sonar { + properties { + property "sonar.projectKey", "team-moabam_moabam-BE" + property "sonar.organization", "team-moabam-sonarcloud-secret-key" + property "sonar.host.url", "https://sonarcloud.io" + property 'sonar.coverage.jacoco.xmlReportPaths', 'build/reports/jacoco/test/jacocoTestReport.xml' + } } From 2b6a6f0918af452fe2b7132b6acd7b9547ac3884 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 27 Oct 2023 15:59:03 +0900 Subject: [PATCH 002/185] =?UTF-8?q?chore:=20checkstyle=20=EC=84=B8?= =?UTF-8?q?=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .editorconfig | 18 + build.gradle | 43 ++ config/checkstyle/checkstyle.xml | 444 +++++++++++++++++++++ config/checkstyle/suppressions.xml | 7 + config/naver-checkstyle-rules.xml | 433 ++++++++++++++++++++ config/naver-intellij-formatter-custom.xml | 74 ++++ 6 files changed, 1019 insertions(+) create mode 100644 .editorconfig create mode 100644 config/checkstyle/checkstyle.xml create mode 100644 config/checkstyle/suppressions.xml create mode 100644 config/naver-checkstyle-rules.xml create mode 100644 config/naver-intellij-formatter-custom.xml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..855c0c54 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +trim_trailing_whitespace = true + +[*.bat] +end_of_line = crlf + +[*.java] +indent_style = tab +ij_java_blank_lines_after_class_header = 1 diff --git a/build.gradle b/build.gradle index fb1017b4..1fc75afe 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,7 @@ plugins { id 'io.spring.dependency-management' version '1.1.3' id 'org.sonarqube' version '4.4.1.3373' id 'jacoco' + id 'checkstyle' } group = 'com.moabam' @@ -64,6 +65,44 @@ jacocoTestReport { xml.required = true html.required = true } + def Qdomains = [] + + for (qPattern in '**/QA'..'**/QZ') { // qPattern = '**/QA', '**/QB', ... '*.QZ' + Qdomains.add(qPattern + '*') + } + + afterEvaluate { + classDirectories.setFrom( + files(classDirectories.files.collect { + fileTree(dir: it, excludes: [ + "**/*Application*", + "**/*Config*", + "**/*Request*", + "**/*Response*", + "**/*Exception*", + "**/*Mapper*", + "**/*ErrorMessage*", + ] + Qdomains) + }) + ) + } +} + +compileJava.options.encoding = 'UTF-8' +compileTestJava.options.encoding = 'UTF-8' + +tasks.withType(Checkstyle).configureEach { + reports { + xml.required = true + html.required = true + } +} + +checkstyle { + toolVersion = "10.4" + maxWarnings = 0 + configFile = file("${rootDir}/config/checkstyle/checkstyle.xml") + configProperties = ["suppressionFile": "${rootDir}/config/checkstyle/suppressions.xml"] } sonar { @@ -72,5 +111,9 @@ sonar { property "sonar.organization", "team-moabam-sonarcloud-secret-key" 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' + property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml new file mode 100644 index 00000000..217fa012 --- /dev/null +++ b/config/checkstyle/checkstyle.xml @@ -0,0 +1,444 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml new file mode 100644 index 00000000..3f11e0cd --- /dev/null +++ b/config/checkstyle/suppressions.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/config/naver-checkstyle-rules.xml b/config/naver-checkstyle-rules.xml new file mode 100644 index 00000000..dafbb4d1 --- /dev/null +++ b/config/naver-checkstyle-rules.xml @@ -0,0 +1,433 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/config/naver-intellij-formatter-custom.xml b/config/naver-intellij-formatter-custom.xml new file mode 100644 index 00000000..26f28954 --- /dev/null +++ b/config/naver-intellij-formatter-custom.xml @@ -0,0 +1,74 @@ + + + \ No newline at end of file From 919bc4ed1ca457c0433bc88c12e9e75997323b52 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 27 Oct 2023 16:00:55 +0900 Subject: [PATCH 003/185] =?UTF-8?q?chore:=20gitignore=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index cf3974ce..df8fb3c3 100644 --- a/.gitignore +++ b/.gitignore @@ -120,3 +120,4 @@ gradle-app.setting logs/ application-*.yml +src/main/resources/config From 7306cca8e7421a6acc183791f1f9333f06b692fe Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Fri, 27 Oct 2023 16:10:35 +0900 Subject: [PATCH 004/185] =?UTF-8?q?docs:=20PR=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9a2d24f4..7362d363 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,11 +1,10 @@ - +## 📋 Checklist +- [ ] 🔀 PR 제목의 형식을 잘 작성했나요? (e.g. `feat: 유저 조회 기능 구현`) +- [ ] 🏷️ 라벨, 프로젝트, 마일스톤은 등록했나요? +- [ ] 🧹 코드 스멜은 해결했나요? ## 🧩 이슈 번호 - #이슈번호 -## ✅ 작업 사항 - -- [ ] 작업 내용 - ## 👩‍💻 공유 포인트 및 논의 사항 From 0aae2ccfee805d43e146df017f5982fbdbd0a302 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Fri, 27 Oct 2023 18:29:16 +0900 Subject: [PATCH 005/185] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=EB=90=9C=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=20=EC=B6=94=EA=B0=80=20=EC=9E=91?= =?UTF-8?q?=EC=97=85=20(#4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Exception 관련 클래스 추가 * feat: Config 관련 클래스 추가 * feat: Entity 관련 클래스 추가 --- .../global/common/entity/BaseTimeEntity.java | 29 ++++++++ .../global/common/util/DynamicQuery.java | 37 ++++++++++ .../com/moabam/global/config/JpaConfig.java | 23 +++++++ .../com/moabam/global/config/WebConfig.java | 23 +++++++ .../error/exception/BadRequestException.java | 10 +++ .../error/exception/ConflictException.java | 10 +++ .../error/exception/ForbiddenException.java | 10 +++ .../error/exception/MoabamException.java | 13 ++++ .../error/exception/NotFoundException.java | 13 ++++ .../exception/UnauthorizedException.java | 10 +++ .../error/handler/GlobalExceptionHandler.java | 68 +++++++++++++++++++ .../global/error/model/ErrorMessage.java | 13 ++++ .../global/error/model/ErrorResponse.java | 11 +++ 13 files changed, 270 insertions(+) create mode 100644 src/main/java/com/moabam/global/common/entity/BaseTimeEntity.java create mode 100644 src/main/java/com/moabam/global/common/util/DynamicQuery.java create mode 100644 src/main/java/com/moabam/global/config/JpaConfig.java create mode 100644 src/main/java/com/moabam/global/config/WebConfig.java create mode 100644 src/main/java/com/moabam/global/error/exception/BadRequestException.java create mode 100644 src/main/java/com/moabam/global/error/exception/ConflictException.java create mode 100644 src/main/java/com/moabam/global/error/exception/ForbiddenException.java create mode 100644 src/main/java/com/moabam/global/error/exception/MoabamException.java create mode 100644 src/main/java/com/moabam/global/error/exception/NotFoundException.java create mode 100644 src/main/java/com/moabam/global/error/exception/UnauthorizedException.java create mode 100644 src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java create mode 100644 src/main/java/com/moabam/global/error/model/ErrorMessage.java create mode 100644 src/main/java/com/moabam/global/error/model/ErrorResponse.java 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/DynamicQuery.java b/src/main/java/com/moabam/global/common/util/DynamicQuery.java new file mode 100644 index 00000000..31fa8bb9 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/DynamicQuery.java @@ -0,0 +1,37 @@ +package com.moabam.global.common.util; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; + +import org.springframework.util.CollectionUtils; + +import com.querydsl.core.types.dsl.BooleanExpression; + +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 filterCondition(T condition, Function function) { + T tempCondition = condition; + + if (tempCondition instanceof List c && CollectionUtils.isEmpty(c)) { + tempCondition = null; + } + + return Optional.ofNullable(tempCondition) + .map(function) + .orElse(null); + } +} 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..9f0b6906 --- /dev/null +++ b/src/main/java/com/moabam/global/config/JpaConfig.java @@ -0,0 +1,23 @@ +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.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/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java new file mode 100644 index 00000000..2f276c96 --- /dev/null +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -0,0 +1,23 @@ +package com.moabam.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; + private static final String ALLOW_ORIGIN_PATTERN = "[a-z]+\\.moabam.com"; + private static final String ALLOW_LOCAL_HOST = "http://localhost:3000"; + + @Override + public void addCorsMappings(final CorsRegistry registry) { + registry.addMapping("/**") + .allowedOriginPatterns(ALLOW_ORIGIN_PATTERN, ALLOW_LOCAL_HOST) + .allowedMethods(ALLOWED_METHOD_NAMES.split(",")) + .allowedHeaders("*") + .allowCredentials(true) + .maxAge(3600); + } +} 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/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..e4a8b5ec --- /dev/null +++ b/src/main/java/com/moabam/global/error/exception/MoabamException.java @@ -0,0 +1,13 @@ +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; + } +} 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/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..2653c8d5 --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -0,0 +1,68 @@ +package com.moabam.global.error.handler; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +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 com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +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.UnauthorizedException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.global.error.model.ErrorResponse; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(NotFoundException.class) + protected ErrorResponse handleNotFoundException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + + @ResponseStatus(HttpStatus.UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + protected ErrorResponse handleUnauthorizedException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + + @ResponseStatus(HttpStatus.NOT_FOUND) + @ExceptionHandler(ForbiddenException.class) + protected ErrorResponse handleForbiddenException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + + @ResponseStatus(HttpStatus.CONFLICT) + @ExceptionHandler(ConflictException.class) + protected ErrorResponse handleConflictException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(BadRequestException.class) + protected ErrorResponse handleBadRequestException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException moabamException) { + List fieldErrors = moabamException.getBindingResult().getFieldErrors(); + Map validation = new HashMap<>(); + + for (FieldError fieldError : fieldErrors) { + validation.put(fieldError.getField(), fieldError.getDefaultMessage()); + } + + return new ErrorResponse(ErrorMessage.INVALID_REQUEST_FIELD.getMessage(), validation); + } +} 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..662874f6 --- /dev/null +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -0,0 +1,13 @@ +package com.moabam.global.error.model; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + + INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."); + + 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 +) { +} From 4e1f0cc1cf4190157f13b830ccbe42cc8e0528b0 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 30 Oct 2023 01:00:08 +0900 Subject: [PATCH 006/185] fix: intellij-formatter line-separator (#10) --- .editorconfig | 18 - config/naver-checkstyle-rules.xml | 433 --------------------- config/naver-intellij-formatter-custom.xml | 143 +++---- 3 files changed, 72 insertions(+), 522 deletions(-) delete mode 100644 .editorconfig delete mode 100644 config/naver-checkstyle-rules.xml diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index 855c0c54..00000000 --- a/.editorconfig +++ /dev/null @@ -1,18 +0,0 @@ -root = true - -[*] -charset = utf-8 -end_of_line = lf -indent_size = 4 -indent_style = space -insert_final_newline = true -max_line_length = 120 -tab_width = 4 -trim_trailing_whitespace = true - -[*.bat] -end_of_line = crlf - -[*.java] -indent_style = tab -ij_java_blank_lines_after_class_header = 1 diff --git a/config/naver-checkstyle-rules.xml b/config/naver-checkstyle-rules.xml deleted file mode 100644 index dafbb4d1..00000000 --- a/config/naver-checkstyle-rules.xml +++ /dev/null @@ -1,433 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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 From 136beea036b51d11cf6d0e4cc2284c2f18940459 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Tue, 31 Oct 2023 21:09:35 +0900 Subject: [PATCH 007/185] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20Authorizat?= =?UTF-8?q?ion=20Grant=EC=99=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B0=98?= =?UTF-8?q?=ED=99=98=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 --- .gitignore | 1 + build.gradle | 3 + .../com/moabam/MoabamServerApplication.java | 3 +- .../application/AuthenticationService.java | 57 +++++++++ .../java/com/moabam/api/domain/Member.java | 96 ++++++++++++++ src/main/java/com/moabam/api/domain/Role.java | 8 ++ .../api/dto/AuthorizationCodeRequest.java | 26 ++++ .../com/moabam/api/mapper/OAuthMapper.java | 19 +++ .../api/presentation/MemberController.java | 23 ++++ .../global/common/util/BaseImageUrl.java | 10 ++ .../global/common/util/GlobalConstant.java | 11 ++ .../common/util/OAuthParameterNames.java | 14 +++ .../com/moabam/global/config/OAuthConfig.java | 28 +++++ .../global/error/model/ErrorMessage.java | 4 +- src/main/resources/config | 2 +- .../AuthenticationServiceTest.java | 118 ++++++++++++++++++ .../com/moabam/api/domain/MemberTest.java | 70 +++++++++++ .../presentation/MemberControllerTest.java | 66 ++++++++++ src/test/resources/application-test.yml | 14 +++ 19 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/AuthenticationService.java create mode 100644 src/main/java/com/moabam/api/domain/Member.java create mode 100644 src/main/java/com/moabam/api/domain/Role.java create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java create mode 100644 src/main/java/com/moabam/api/mapper/OAuthMapper.java create mode 100644 src/main/java/com/moabam/api/presentation/MemberController.java create mode 100644 src/main/java/com/moabam/global/common/util/BaseImageUrl.java create mode 100644 src/main/java/com/moabam/global/common/util/GlobalConstant.java create mode 100644 src/main/java/com/moabam/global/common/util/OAuthParameterNames.java create mode 100644 src/main/java/com/moabam/global/config/OAuthConfig.java create mode 100644 src/test/java/com/moabam/api/application/AuthenticationServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/MemberTest.java create mode 100644 src/test/java/com/moabam/api/presentation/MemberControllerTest.java create mode 100644 src/test/resources/application-test.yml diff --git a/.gitignore b/.gitignore index df8fb3c3..f4a54d77 100644 --- a/.gitignore +++ b/.gitignore @@ -121,3 +121,4 @@ gradle-app.setting logs/ application-*.yml src/main/resources/config +!application-test.yml diff --git a/build.gradle b/build.gradle index 1fc75afe..0bb89395 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { // H2 implementation 'com.h2database:h2' + + // Configuration Binding + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" } tasks.named('test') { 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/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java new file mode 100644 index 00000000..bd383e95 --- /dev/null +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -0,0 +1,57 @@ +package com.moabam.api.application; + +import static com.moabam.global.common.util.OAuthParameterNames.*; + +import java.io.IOException; + +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.mapper.OAuthMapper; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AuthenticationService { + + private final OAuthConfig oAuthConfig; + + private String getAuthorizaionCodeUri() { + AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); + return generateQueryParamsWith(authorizationCodeRequest); + } + + 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().isEmpty()) { + String scopes = String.join(GlobalConstant.COMMA, authorizationCodeRequest.scope()); + authorizationCodeUri.queryParam(SCOPE, scopes); + } + + return authorizationCodeUri.toUriString(); + } + + public void redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizaionCodeUri(); + + try { + httpServletResponse.setContentType(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + httpServletResponse.sendRedirect(authorizationCodeUri); + } catch (IOException e) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILD); + } + } +} diff --git a/src/main/java/com/moabam/api/domain/Member.java b/src/main/java/com/moabam/api/domain/Member.java new file mode 100644 index 00000000..c2e3701e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/Member.java @@ -0,0 +1,96 @@ +package com.moabam.api.domain; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.common.util.BaseImageUrl; + +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; + +@Getter +@Entity +@Table(name = "member") +@SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where participant_id = ?") +@Where(clause = "deleted_at IS NOT NULL") +@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", nullable = false, unique = true) + private String nickname; + + @Column(name = "intro", length = 30) + private String intro; + + @Column(name = "profile_image", nullable = false) + private String profileImage; + + @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; + + @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; + + @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, String nickname, String profileImage) { + this.id = id; + this.socialId = requireNonNull(socialId); + this.nickname = requireNonNull(nickname); + this.profileImage = requireNonNullElse(profileImage, BaseImageUrl.PROFILE_URL); + this.role = Role.USER; + } +} diff --git a/src/main/java/com/moabam/api/domain/Role.java b/src/main/java/com/moabam/api/domain/Role.java new file mode 100644 index 00000000..65cb1a49 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/Role.java @@ -0,0 +1,8 @@ +package com.moabam.api.domain; + +public enum Role { + + USER, + BLACK, + ADMIN +} diff --git a/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java b/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java new file mode 100644 index 00000000..b6c30db5 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java @@ -0,0 +1,26 @@ +package com.moabam.api.dto; + +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/mapper/OAuthMapper.java b/src/main/java/com/moabam/api/mapper/OAuthMapper.java new file mode 100644 index 00000000..7d16b8ef --- /dev/null +++ b/src/main/java/com/moabam/api/mapper/OAuthMapper.java @@ -0,0 +1,19 @@ +package com.moabam.api.mapper; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.global.config.OAuthConfig; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthMapper { + + public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) { + return AuthorizationCodeRequest.builder() + .clientId(oAuthConfig.client().clientId()) + .redirectUri(oAuthConfig.provider().redirectUri()) + .scope(oAuthConfig.client().scope()) + .build(); + } +} 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..fd2e730f --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -0,0 +1,23 @@ +package com.moabam.api.presentation; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.AuthenticationService; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/members") +@RequiredArgsConstructor +public class MemberController { + + private final AuthenticationService authenticationService; + + @GetMapping + public void socialLogin(HttpServletResponse httpServletResponse) { + authenticationService.redirectToLoginPage(httpServletResponse); + } +} 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..d13f36ff --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -0,0 +1,10 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class BaseImageUrl { + + public static final String PROFILE_URL = "/profile/baseUrl"; +} 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..5b87cd58 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -0,0 +1,11 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class GlobalConstant { + + public static final String COMMA = ","; + public static final String CHARSET_UTF_8 = ";charset=UTF-8"; +} diff --git a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java new file mode 100644 index 00000000..efc65e59 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java @@ -0,0 +1,14 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class OAuthParameterNames { + + public static final String RESPONSE_TYPE = "response_type"; + public static final String CODE = "code"; + public static final String CLIENT_ID = "client_id"; + public static final String REDIRECT_URI = "redirect_uri"; + public static final String SCOPE = "scope"; +} 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..cff7f45b --- /dev/null +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -0,0 +1,28 @@ +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 authorizationGrantType, + List scope + ) { + + } + + public record Provider( + String authorizationUri, + String redirectUri + ) { + + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 662874f6..26d157ea 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -7,7 +7,9 @@ @RequiredArgsConstructor public enum ErrorMessage { - INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."); + INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + LOGIN_FAILED("로그인에 실패했습니다."), + REQUEST_FAILD("네트우크 접근 실패입니다."); private final String message; } diff --git a/src/main/resources/config b/src/main/resources/config index 8bc59e64..90404393 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 8bc59e6455ce1220e00acf676849951cbd935373 +Subproject commit 90404393aafb50e5650b81f6b67b69adc825e938 diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java new file mode 100644 index 00000000..cfebb663 --- /dev/null +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -0,0 +1,118 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.io.IOException; +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.mockito.InjectMocks; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.mapper.OAuthMapper; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; + +@ExtendWith(MockitoExtension.class) +class AuthenticationServiceTest { + + @InjectMocks + AuthenticationService authenticationService; + OAuthConfig oauthConfig; + AuthenticationService noPropertyService; + OAuthConfig noOAuthConfig; + + @BeforeEach + public void initParams() { + oauthConfig = new OAuthConfig( + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url"), + new OAuthConfig.Client("provider", "testtestetsttest", "authorization_code", + List.of("profile_nickname", "profile_image")) + ); + ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); + + noOAuthConfig = new OAuthConfig( + new OAuthConfig.Provider(null, null), + new OAuthConfig.Client(null, null, null, null) + ); + noPropertyService = new AuthenticationService(noOAuthConfig); + + } + + @DisplayName("인가코드 URI 생성 매퍼 실패") + @Test + void authorization_code_request_mapping_fail() { + // When + Then + Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationCodeRequest(noOAuthConfig)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("인가코드 URI 생성 매퍼 성공") + @Test + void authorization_code_request_mapping_success() { + // Given + AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oauthConfig); + + // When + Then + assertThat(authorizationCodeRequest).isNotNull(); + assertAll( + () -> assertThat(authorizationCodeRequest.clientId()).isEqualTo(oauthConfig.client().clientId()), + () -> assertThat(authorizationCodeRequest.redirectUri()).isEqualTo(oauthConfig.provider().redirectUri()) + ); + } + + @DisplayName("인가코드 URI 생성 성공") + @Test + void authorization_code_uri_generate_success() throws IOException { + // given + String uri = "https://authorization/url?" + + "response_type=code&" + + "client_id=testtestetsttest&" + + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; + + MockHttpServletResponse mockHttpServletResponse = mockHttpServletResponse = new MockHttpServletResponse(); + + // when + authenticationService.redirectToLoginPage(mockHttpServletResponse); + + // then + assertThat(mockHttpServletResponse.getContentType()) + .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); + assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); + } + + @DisplayName("redirect 실패 테스트") + @Test + void redirect_fail_test() { + // given + HttpServletResponse mockHttpServletResponse = Mockito.mock(HttpServletResponse.class); + + try { + doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); + + assertThatThrownBy(() -> { + // When + Then + authenticationService.redirectToLoginPage(mockHttpServletResponse); + }).isExactlyInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.REQUEST_FAILD.getMessage()); + } catch (Exception ignored) { + + } + } +} diff --git a/src/test/java/com/moabam/api/domain/MemberTest.java b/src/test/java/com/moabam/api/domain/MemberTest.java new file mode 100644 index 00000000..b08dde3f --- /dev/null +++ b/src/test/java/com/moabam/api/domain/MemberTest.java @@ -0,0 +1,70 @@ +package com.moabam.api.domain; + +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 com.moabam.global.common.util.BaseImageUrl; + +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) + .nickname(nickname) + .profileImage(profileImage) + .build()); + } + + @DisplayName("프로필 이미지 없이 회원 생성 성공") + @Test + void create_member_noImage_success() { + // When + Then + assertThatNoException().isThrownBy(() -> { + Member member = Member.builder() + .socialId(socialId) + .nickname(nickname) + .profileImage(null) + .build(); + + assertAll( + () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.PROFILE_URL), + () -> assertThat(member.getRole()).isEqualTo(Role.USER), + () -> assertThat(member.getNightBug()).isZero(), + () -> assertThat(member.getGoldenBug()).isZero(), + () -> assertThat(member.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() + .nickname(nickname)::build) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("닉네임에 따른 회원 생성 실패") + @Test + void create_member_failBy_nickname() { + // When + Then + assertThatThrownBy(Member.builder() + .socialId(socialId)::build) + .isInstanceOf(NullPointerException.class); + } +} 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..cd4da732 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -0,0 +1,66 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.common.util.OAuthParameterNames.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +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.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.test.web.servlet.ResultActions; +import org.springframework.web.util.UriComponentsBuilder; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.AuthenticationService; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.config.OAuthConfig; + +@SpringBootTest( + properties = { + "oauth2.provider.authorization-uri=https://kauth.kakao.com/oauth/authorize", + "oauth2.provider.redirect-uri=http://localhost:8080/members/login/kakao/oauth", + "oauth2.client.client-id=testtesttesttesttesttesttesttesttest", + "oauth2.client.authorization-grant-type=authorization_code", + "oauth2.client.scope=profile_nickname,profile_image" + } +) +@AutoConfigureMockMvc +class MemberControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @Autowired + AuthenticationService authenticationService; + + @Autowired + OAuthConfig oAuthConfig; + + @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")); + + result.andExpect(status().is3xxRedirection()) + .andExpect(header().string("Content-type", + MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) + .andExpect(redirectedUrl(uri)); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..b149d76f --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,14 @@ +oauth2: + client: + provider: test + client-id: testtestetsttest + client-secret: testtestetsttest + authorization-grant-type: authorization_code + scope: + - profile_nickname + - profile_image + + provider: + authorization_uri: https://test.com/test/test + redirect_uri: http://test:8080/test + token_uri: https://test.test.com/test/test From ec1bc6d1d5c5b3f97454af8a86354ecdc3b774e1 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Wed, 1 Nov 2023 15:17:42 +0900 Subject: [PATCH 008/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=83=9D=EC=84=B1,?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= =?UTF-8?q?=20(#20)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Room, Participant, Routine, Certification 엔티티 생성 * feat: Room 엔티티 인증 시간 검증 로직 추가 * test: Room 엔티티 테스트 코드 작성 * refactor: Room 관련 엔티티 수정 * feat: 방 생성 기능 구현 * chore: DynamicQuery Jacoco 예외 추가 * test: 방 생성 테스트 코드 작성 * feat: 방 수정 기능 구현 * test: 방 수정 통합 테스트 작성 * refactor: Member 관련 파일 이동 * refactor: checkStyle에 맞춰서 변경 * test: 추가 테스트 코드 작성 * refactor: 코드 리뷰 반영 * refactor: 불필요한 메서드 삭제 --- build.gradle | 28 +- .../application/AuthenticationService.java | 2 +- .../moabam/api/application/RoomService.java | 65 ++++ .../api/domain/entity/Certification.java | 53 ++++ .../api/domain/{ => entity}/Member.java | 3 +- .../moabam/api/domain/entity/Participant.java | 71 +++++ .../com/moabam/api/domain/entity/Room.java | 134 ++++++++ .../com/moabam/api/domain/entity/Routine.java | 49 +++ .../api/domain/{ => entity/enums}/Role.java | 2 +- .../api/domain/entity/enums/RoomType.java | 7 + .../repository/CertificationRepository.java | 9 + .../repository/ParticipantRepository.java | 9 + .../ParticipantSearchRepository.java | 30 ++ .../api/domain/repository/RoomRepository.java | 9 + .../domain/repository/RoutineRepository.java | 9 + .../com/moabam/api/dto/CreateRoomRequest.java | 23 ++ .../com/moabam/api/dto/ModifyRoomRequest.java | 15 + .../api/{mapper => dto}/OAuthMapper.java | 2 +- .../java/com/moabam/api/dto/RoomMapper.java | 32 ++ .../api/presentation/RoomController.java | 38 +++ .../global/error/model/ErrorMessage.java | 4 + src/main/resources/config | 2 +- .../AuthenticationServiceTest.java | 2 +- .../api/application/RoomServiceTest.java | 83 +++++ .../api/domain/entity/CertificationTest.java | 41 +++ .../api/domain/{ => entity}/MemberTest.java | 3 +- .../moabam/api/domain/entity/RoomTest.java | 92 ++++++ .../api/presentation/RoomControllerTest.java | 287 ++++++++++++++++++ 28 files changed, 1084 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/RoomService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Certification.java rename src/main/java/com/moabam/api/domain/{ => entity}/Member.java (96%) create mode 100644 src/main/java/com/moabam/api/domain/entity/Participant.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Room.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Routine.java rename src/main/java/com/moabam/api/domain/{ => entity/enums}/Role.java (50%) create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/RoomType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/CertificationRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/RoomRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/RoutineRepository.java create mode 100644 src/main/java/com/moabam/api/dto/CreateRoomRequest.java create mode 100644 src/main/java/com/moabam/api/dto/ModifyRoomRequest.java rename src/main/java/com/moabam/api/{mapper => dto}/OAuthMapper.java (94%) create mode 100644 src/main/java/com/moabam/api/dto/RoomMapper.java create mode 100644 src/main/java/com/moabam/api/presentation/RoomController.java create mode 100644 src/test/java/com/moabam/api/application/RoomServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/CertificationTest.java rename src/test/java/com/moabam/api/domain/{ => entity}/MemberTest.java (95%) create mode 100644 src/test/java/com/moabam/api/domain/entity/RoomTest.java create mode 100644 src/test/java/com/moabam/api/presentation/RoomControllerTest.java diff --git a/build.gradle b/build.gradle index 0bb89395..0943c572 100644 --- a/build.gradle +++ b/build.gradle @@ -76,17 +76,19 @@ 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*", + ] + Qdomains) + }) ) } } @@ -115,8 +117,8 @@ 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' property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index bd383e95..7c407afc 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -9,7 +9,7 @@ import org.springframework.web.util.UriComponentsBuilder; import com.moabam.api.dto.AuthorizationCodeRequest; -import com.moabam.api.mapper.OAuthMapper; +import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java new file mode 100644 index 00000000..b16e6a06 --- /dev/null +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -0,0 +1,65 @@ +package com.moabam.api.application; + +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.domain.entity.Participant; +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.repository.ParticipantRepository; +import com.moabam.api.domain.repository.ParticipantSearchRepository; +import com.moabam.api.domain.repository.RoomRepository; +import com.moabam.api.domain.repository.RoutineRepository; +import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.ModifyRoomRequest; +import com.moabam.api.dto.RoomMapper; +import com.moabam.global.error.exception.ForbiddenException; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomService { + + private final RoomRepository roomRepository; + private final RoutineRepository routineRepository; + private final ParticipantRepository participantRepository; + private final ParticipantSearchRepository participantSearchRepository; + + @Transactional + public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { + Room room = RoomMapper.toRoomEntity(createRoomRequest); + List routines = RoomMapper.toRoutineEntity(room, createRoomRequest.routines()); + Participant participant = Participant.builder() + .room(room) + .memberId(memberId) + .build(); + participant.enableManager(); + roomRepository.save(room); + routineRepository.saveAll(routines); + participantRepository.save(participant); + } + + @Transactional + public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomRequest) { + // TODO: 추후에 별도 메서드로 뺄듯 + Participant participant = participantSearchRepository.findParticipant(roomId, memberId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + + if (!participant.isManager()) { + throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); + } + + Room room = roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + room.changeTitle(modifyRoomRequest.title()); + room.changePassword(modifyRoomRequest.password()); + room.changeCertifyTime(modifyRoomRequest.certifyTime()); + room.changeMaxCount(modifyRoomRequest.maxUserCount()); + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Certification.java b/src/main/java/com/moabam/api/domain/entity/Certification.java new file mode 100644 index 00000000..eeff890e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Certification.java @@ -0,0 +1,53 @@ +package com.moabam.api.domain.entity; + +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/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java similarity index 96% rename from src/main/java/com/moabam/api/domain/Member.java rename to src/main/java/com/moabam/api/domain/entity/Member.java index c2e3701e..a0a9e749 100644 --- a/src/main/java/com/moabam/api/domain/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain; +package com.moabam.api.domain.entity; import static java.util.Objects.*; @@ -8,6 +8,7 @@ import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.Where; +import com.moabam.api.domain.entity.enums.Role; import com.moabam.global.common.entity.BaseTimeEntity; import com.moabam.global.common.util.BaseImageUrl; diff --git a/src/main/java/com/moabam/api/domain/entity/Participant.java b/src/main/java/com/moabam/api/domain/entity/Participant.java new file mode 100644 index 00000000..733585cd --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Participant.java @@ -0,0 +1,71 @@ +package com.moabam.api.domain.entity; + +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.SQLDelete; + +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 { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "room_id", updatable = false, nullable = false) + 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; + + @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; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/entity/Room.java new file mode 100644 index 00000000..d4ad6c6b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Room.java @@ -0,0 +1,134 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.api.domain.entity.enums.RoomType.*; +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.entity.enums.RoomType; +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) +public class Room extends BaseTimeEntity { + + private static final String ROOM_LEVEL_0_IMAGE = "'temptemp'"; + private static final String ROOM_LEVEL_10_IMAGE = "'temp'"; + private static final String ROOM_LEVEL_20_IMAGE = "'tempp'"; + 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; + + // TODO: 한글 10자도 맞나? + @Column(name = "title", nullable = false, length = 30) + private String title; + + @Column(name = "password", length = 8) + private String password; + + @Column(name = "level", nullable = false) + private int level; + + @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; + + // TODO: 한글 길이 고려 + @Column(name = "announcement", length = 255) + private String announcement; + + @ColumnDefault(ROOM_LEVEL_0_IMAGE) + @Column(name = "room_image", length = 500) + private String roomImage; + + @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.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; + } + + 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 changeMaxCount(int maxUserCount) { + if (maxUserCount < this.currentUserCount) { + throw new BadRequestException(ROOM_MAX_USER_COUNT_MODIFY_FAIL); + } + + this.maxUserCount = maxUserCount; + } + + public void upgradeRoomImage(String roomImage) { + this.roomImage = roomImage; + } + + 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/entity/Routine.java b/src/main/java/com/moabam/api/domain/entity/Routine.java new file mode 100644 index 00000000..26015bb2 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Routine.java @@ -0,0 +1,49 @@ +package com.moabam.api.domain.entity; + +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 = "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", nullable = false, updatable = false) + private Room room; + + @Column(name = "content", nullable = false, length = 60) + private String content; + + @Builder + private Routine(Long id, Room room, String content) { + this.id = id; + this.room = requireNonNull(room); + this.content = requireNonNull(content); + } + + public void changeContent(String content) { + this.content = content; + } +} diff --git a/src/main/java/com/moabam/api/domain/Role.java b/src/main/java/com/moabam/api/domain/entity/enums/Role.java similarity index 50% rename from src/main/java/com/moabam/api/domain/Role.java rename to src/main/java/com/moabam/api/domain/entity/enums/Role.java index 65cb1a49..b90adc44 100644 --- a/src/main/java/com/moabam/api/domain/Role.java +++ b/src/main/java/com/moabam/api/domain/entity/enums/Role.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain; +package com.moabam.api.domain.entity.enums; public enum Role { diff --git a/src/main/java/com/moabam/api/domain/entity/enums/RoomType.java b/src/main/java/com/moabam/api/domain/entity/enums/RoomType.java new file mode 100644 index 00000000..e9f8342a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/RoomType.java @@ -0,0 +1,7 @@ +package com.moabam.api.domain.entity.enums; + +public enum RoomType { + + MORNING, + NIGHT +} diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationRepository.java b/src/main/java/com/moabam/api/domain/repository/CertificationRepository.java new file mode 100644 index 00000000..4d794056 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/CertificationRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Certification; + +public interface CertificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java new file mode 100644 index 00000000..3f0a7bfc --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Participant; + +public interface ParticipantRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java new file mode 100644 index 00000000..e80e81b9 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java @@ -0,0 +1,30 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QParticipant.*; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.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 findParticipant(Long roomId, Long memberId) { + return Optional.ofNullable( + jpaQueryFactory.selectFrom(participant) + .where( + DynamicQuery.generateEq(roomId, participant.room.id::eq), + DynamicQuery.generateEq(memberId, participant.memberId::eq) + ).fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/RoomRepository.java b/src/main/java/com/moabam/api/domain/repository/RoomRepository.java new file mode 100644 index 00000000..96c8d99f --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/RoomRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Room; + +public interface RoomRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/RoutineRepository.java b/src/main/java/com/moabam/api/domain/repository/RoutineRepository.java new file mode 100644 index 00000000..099e82da --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/RoutineRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Routine; + +public interface RoutineRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/dto/CreateRoomRequest.java b/src/main/java/com/moabam/api/dto/CreateRoomRequest.java new file mode 100644 index 00000000..516564ea --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CreateRoomRequest.java @@ -0,0 +1,23 @@ +package com.moabam.api.dto; + +import java.util.List; + +import org.hibernate.validator.constraints.Range; + +import com.moabam.api.domain.entity.enums.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 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/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java new file mode 100644 index 00000000..4e24341a --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto; + +import org.hibernate.validator.constraints.Range; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record ModifyRoomRequest( + @NotBlank String title, + @Pattern(regexp = "^(|[0-9]{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/mapper/OAuthMapper.java b/src/main/java/com/moabam/api/dto/OAuthMapper.java similarity index 94% rename from src/main/java/com/moabam/api/mapper/OAuthMapper.java rename to src/main/java/com/moabam/api/dto/OAuthMapper.java index 7d16b8ef..dac14930 100644 --- a/src/main/java/com/moabam/api/mapper/OAuthMapper.java +++ b/src/main/java/com/moabam/api/dto/OAuthMapper.java @@ -1,4 +1,4 @@ -package com.moabam.api.mapper; +package com.moabam.api.dto; import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.global.config.OAuthConfig; diff --git a/src/main/java/com/moabam/api/dto/RoomMapper.java b/src/main/java/com/moabam/api/dto/RoomMapper.java new file mode 100644 index 00000000..9ac332c2 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/RoomMapper.java @@ -0,0 +1,32 @@ +package com.moabam.api.dto; + +import java.util.List; + +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.Routine; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public abstract 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 List toRoutineEntity(Room room, List routinesRequest) { + return routinesRequest.stream() + .map(routine -> Routine.builder() + .room(room) + .content(routine) + .build()) + .toList(); + } +} 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..c0edc651 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -0,0 +1,38 @@ +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.PutMapping; +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.RoomService; +import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.ModifyRoomRequest; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/rooms") +public class RoomController { + + private final RoomService roomService; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void createRoom(@Valid @RequestBody CreateRoomRequest createRoomRequest) { + roomService.createRoom(1L, createRoomRequest); + } + + @PutMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void modifyRoom(@Valid @RequestBody ModifyRoomRequest modifyRoomRequest, + @PathVariable("roomId") Long roomId) { + roomService.modifyRoom(1L, roomId, modifyRoomRequest); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 26d157ea..82942270 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -8,6 +8,10 @@ public enum ErrorMessage { INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + ROOM_NOT_FOUND("존재하지 않는 방 입니다."), + ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), + ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), + PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILD("네트우크 접근 실패입니다."); diff --git a/src/main/resources/config b/src/main/resources/config index 90404393..8bc59e64 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 90404393aafb50e5650b81f6b67b69adc825e938 +Subproject commit 8bc59e6455ce1220e00acf676849951cbd935373 diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index cfebb663..20fea0af 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -21,7 +21,7 @@ import org.springframework.test.util.ReflectionTestUtils; import com.moabam.api.dto.AuthorizationCodeRequest; -import com.moabam.api.mapper.OAuthMapper; +import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java new file mode 100644 index 00000000..400d5cbb --- /dev/null +++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java @@ -0,0 +1,83 @@ +package com.moabam.api.application; + +import static com.moabam.api.domain.entity.enums.RoomType.*; +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.ArgumentMatchers; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.entity.Participant; +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.repository.CertificationRepository; +import com.moabam.api.domain.repository.ParticipantRepository; +import com.moabam.api.domain.repository.RoomRepository; +import com.moabam.api.domain.repository.RoutineRepository; +import com.moabam.api.dto.CreateRoomRequest; + +@ExtendWith(MockitoExtension.class) +class RoomServiceTest { + + @InjectMocks + private RoomService roomService; + + @Mock + private RoomRepository roomRepository; + + @Mock + private RoutineRepository routineRepository; + + @Mock + private CertificationRepository certificationRepository; + + @Mock + private ParticipantRepository participantRepository; + + @DisplayName("비밀번호 없는 방 생성 성공") + @Test + void create_room_no_password_success() { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + + // when + roomService.createRoom(1L, createRoomRequest); + + // then + verify(roomRepository).save(any(Room.class)); + verify(routineRepository).saveAll(ArgumentMatchers.anyList()); + verify(participantRepository).save(any(Participant.class)); + } + + @DisplayName("비밀번호 있는 방 생성 성공") + @Test + void create_room_with_password_success() { + // given + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + + CreateRoomRequest createRoomRequest = new CreateRoomRequest( + "재윤과 앵맹이의 방임", "1234", routines, MORNING, 10, 4); + + // when + roomService.createRoom(1L, createRoomRequest); + + // then + verify(roomRepository).save(any(Room.class)); + verify(routineRepository).saveAll(ArgumentMatchers.anyList()); + verify(participantRepository).save(any(Participant.class)); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/CertificationTest.java b/src/test/java/com/moabam/api/domain/entity/CertificationTest.java new file mode 100644 index 00000000..1e14bf29 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/CertificationTest.java @@ -0,0 +1,41 @@ +package com.moabam.api.domain.entity; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.entity.enums.RoomType; + +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/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java similarity index 95% rename from src/test/java/com/moabam/api/domain/MemberTest.java rename to src/test/java/com/moabam/api/domain/entity/MemberTest.java index b08dde3f..dbc695fc 100644 --- a/src/test/java/com/moabam/api/domain/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain; +package com.moabam.api.domain.entity; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; @@ -6,6 +6,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.moabam.api.domain.entity.enums.Role; import com.moabam.global.common.util.BaseImageUrl; class MemberTest { diff --git a/src/test/java/com/moabam/api/domain/entity/RoomTest.java b/src/test/java/com/moabam/api/domain/entity/RoomTest.java new file mode 100644 index 00000000..d4516b62 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/RoomTest.java @@ -0,0 +1,92 @@ +package com.moabam.api.domain.entity; + +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.api.domain.entity.enums.RoomType; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.model.ErrorMessage; + +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("'temptemp'"); + 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()); + } +} 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..55668783 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -0,0 +1,287 @@ +package com.moabam.api.presentation; + +import static com.moabam.api.domain.entity.enums.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.util.ArrayList; +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.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.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.entity.Participant; +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.repository.ParticipantRepository; +import com.moabam.api.domain.repository.RoomRepository; +import com.moabam.api.domain.repository.RoutineRepository; +import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.ModifyRoomRequest; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +class RoomControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private RoutineRepository routineRepository; + + @Autowired + private ParticipantRepository participantRepository; + + @DisplayName("비밀번호 없는 방 생성 성공") + @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("비밀번호 있는 방 생성 성공") + @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("방 수정 성공 - 방장일 경우") + @Test + void modify_room_success() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + Participant participant = Participant.builder() + .room(room) + .memberId(1L) + .build(); + participant.enableManager(); + + Room savedRoom = roomRepository.save(room); + participantRepository.save(participant); + + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); + + String json = objectMapper.writeValueAsString(modifyRoomRequest); + + // expected + mockMvc.perform(put("/rooms/" + savedRoom.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("방 수정 실패 - 방장 아닐 경우") + @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 = Participant.builder() + .room(room) + .memberId(1L) + .build(); + + Room savedRoom = roomRepository.save(room); + participantRepository.save(participant); + + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); + + String json = objectMapper.writeValueAsString(modifyRoomRequest); + + // expected + mockMvc.perform(put("/rooms/" + savedRoom.getId()) + .contentType(APPLICATION_JSON) + .content(json)) + .andExpect(status().isNotFound()) + .andDo(print()); + } +} From 929acc54f25cb72202074aed2a0791eb3695dd80 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Wed, 1 Nov 2023 23:31:12 +0900 Subject: [PATCH 009/185] =?UTF-8?q?feat:=20=EB=B2=8C=EB=A0=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Bug 임베디드 타입 생성 * feat: 벌레 조회 API 구현 * docs: PR merge 시, Issue 자동 close로 수정 * refactor: 엔티티 생성자 id 포함으로 변경 * feat: 벌레 개수 검증 추가 * test: 벌레 조회 서비스 테스트 * style: dto 내 bug 패키지 제거 * test: Bug 도메인 테스트 * style: 테스트 메서드 네이밍 수정 * test: 벌레 조회 controller 테스트 * refactor: private 생성자 추가 * test: 멤버 fixture 생성 및 적용 * test: 벌레 fixture 생성 및 적용 * test: 멤버 엔티티 테스트에 Bug 추가 * fix: code smell 제거 * style: BugMapper 메서드 네이밍 수정 * style: return 전 줄바꿈 추가 * refactor: ResponseStatus + DTO 방식으로 변경 * test: 벌레 개수 검증 테스트에 ParameterizedTest 적용 --- .github/PULL_REQUEST_TEMPLATE.md | 3 +- .../moabam/api/application/BugService.java | 24 ++++++++ .../moabam/api/application/MemberService.java | 25 +++++++++ .../com/moabam/api/domain/entity/Bug.java | 47 ++++++++++++++++ .../com/moabam/api/domain/entity/Member.java | 19 ++----- .../domain/repository/MemberRepository.java | 9 +++ .../java/com/moabam/api/dto/BugMapper.java | 18 ++++++ .../java/com/moabam/api/dto/BugResponse.java | 12 ++++ .../java/com/moabam/api/dto/OAuthMapper.java | 1 - .../api/presentation/BugController.java | 26 +++++++++ .../global/error/model/ErrorMessage.java | 6 +- .../api/application/BugServiceTest.java | 44 +++++++++++++++ .../com/moabam/api/domain/entity/BugTest.java | 30 ++++++++++ .../moabam/api/domain/entity/MemberTest.java | 8 ++- .../api/presentation/BugControllerTest.java | 55 +++++++++++++++++++ .../java/com/moabam/fixture/BugFixture.java | 18 ++++++ .../com/moabam/fixture/MemberFixture.java | 19 +++++++ 17 files changed, 345 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/BugService.java create mode 100644 src/main/java/com/moabam/api/application/MemberService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Bug.java create mode 100644 src/main/java/com/moabam/api/domain/repository/MemberRepository.java create mode 100644 src/main/java/com/moabam/api/dto/BugMapper.java create mode 100644 src/main/java/com/moabam/api/dto/BugResponse.java create mode 100644 src/main/java/com/moabam/api/presentation/BugController.java create mode 100644 src/test/java/com/moabam/api/application/BugServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/BugTest.java create mode 100644 src/test/java/com/moabam/api/presentation/BugControllerTest.java create mode 100644 src/test/java/com/moabam/fixture/BugFixture.java create mode 100644 src/test/java/com/moabam/fixture/MemberFixture.java diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 7362d363..a5e5b65d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,10 +1,11 @@ ## 📋 Checklist + - [ ] 🔀 PR 제목의 형식을 잘 작성했나요? (e.g. `feat: 유저 조회 기능 구현`) - [ ] 🏷️ 라벨, 프로젝트, 마일스톤은 등록했나요? - [ ] 🧹 코드 스멜은 해결했나요? ## 🧩 이슈 번호 -- #이슈번호 +- close #이슈번호 ## 👩‍💻 공유 포인트 및 논의 사항 diff --git a/src/main/java/com/moabam/api/application/BugService.java b/src/main/java/com/moabam/api/application/BugService.java new file mode 100644 index 00000000..74f98623 --- /dev/null +++ b/src/main/java/com/moabam/api/application/BugService.java @@ -0,0 +1,24 @@ +package com.moabam.api.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Member; +import com.moabam.api.dto.BugMapper; +import com.moabam.api.dto.BugResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class BugService { + + private final MemberService memberService; + + public BugResponse getBug(Long memberId) { + Member member = memberService.getById(memberId); + + return BugMapper.toBugResponse(member.getBug()); + } +} diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/MemberService.java new file mode 100644 index 00000000..112fcd7e --- /dev/null +++ b/src/main/java/com/moabam/api/application/MemberService.java @@ -0,0 +1,25 @@ +package com.moabam.api.application; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Member; +import com.moabam.api.domain.repository.MemberRepository; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class MemberService { + + private final MemberRepository memberRepository; + + public Member getById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Bug.java b/src/main/java/com/moabam/api/domain/entity/Bug.java new file mode 100644 index 00000000..12fd020b --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Bug.java @@ -0,0 +1,47 @@ +package com.moabam.api.domain.entity; + +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; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index a0a9e749..f7108bac 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -13,6 +13,7 @@ import com.moabam.global.common.util.BaseImageUrl; import jakarta.persistence.Column; +import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; @@ -25,8 +26,8 @@ import lombok.Getter; import lombok.NoArgsConstructor; -@Getter @Entity +@Getter @Table(name = "member") @SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where participant_id = ?") @Where(clause = "deleted_at IS NOT NULL") @@ -66,17 +67,8 @@ public class Member extends BaseTimeEntity { @ColumnDefault("0") private int currentMorningCount; - @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; + @Embedded + private Bug bug; @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false) @@ -87,11 +79,12 @@ public class Member extends BaseTimeEntity { private LocalDateTime deletedAt; @Builder - private Member(Long id, String socialId, String nickname, String profileImage) { + private Member(Long id, String socialId, String nickname, String profileImage, Bug bug) { this.id = id; this.socialId = requireNonNull(socialId); this.nickname = requireNonNull(nickname); this.profileImage = requireNonNullElse(profileImage, BaseImageUrl.PROFILE_URL); + this.bug = requireNonNull(bug); this.role = Role.USER; } } diff --git a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java new file mode 100644 index 00000000..095c71b4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Member; + +public interface MemberRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/dto/BugMapper.java b/src/main/java/com/moabam/api/dto/BugMapper.java new file mode 100644 index 00000000..57bea850 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/BugMapper.java @@ -0,0 +1,18 @@ +package com.moabam.api.dto; + +import com.moabam.api.domain.entity.Bug; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public final class BugMapper { + + public static BugResponse toBugResponse(Bug bug) { + return BugResponse.builder() + .morningBug(bug.getMorningBug()) + .nightBug(bug.getNightBug()) + .goldenBug(bug.getGoldenBug()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/dto/BugResponse.java b/src/main/java/com/moabam/api/dto/BugResponse.java new file mode 100644 index 00000000..256c18cb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/BugResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record BugResponse( + int morningBug, + int nightBug, + int goldenBug +) { + +} diff --git a/src/main/java/com/moabam/api/dto/OAuthMapper.java b/src/main/java/com/moabam/api/dto/OAuthMapper.java index dac14930..8b82483b 100644 --- a/src/main/java/com/moabam/api/dto/OAuthMapper.java +++ b/src/main/java/com/moabam/api/dto/OAuthMapper.java @@ -1,6 +1,5 @@ package com.moabam.api.dto; -import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.global.config.OAuthConfig; import lombok.AccessLevel; 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..d17dfa32 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -0,0 +1,26 @@ +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.BugService; +import com.moabam.api.dto.BugResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/bugs") +@RequiredArgsConstructor +public class BugController { + + private final BugService bugService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public BugResponse getBug() { + return bugService.getBug(1L); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 82942270..1e381ef0 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -13,7 +13,11 @@ public enum ErrorMessage { ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), - REQUEST_FAILD("네트우크 접근 실패입니다."); + REQUEST_FAILD("네트우크 접근 실패입니다."), + + MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), + + INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."); private final String message; } diff --git a/src/test/java/com/moabam/api/application/BugServiceTest.java b/src/test/java/com/moabam/api/application/BugServiceTest.java new file mode 100644 index 00000000..416ba5fb --- /dev/null +++ b/src/test/java/com/moabam/api/application/BugServiceTest.java @@ -0,0 +1,44 @@ +package com.moabam.api.application; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.entity.Bug; +import com.moabam.api.domain.entity.Member; +import com.moabam.api.dto.BugResponse; +import com.moabam.fixture.MemberFixture; + +@ExtendWith(MockitoExtension.class) +class BugServiceTest { + + @InjectMocks + BugService bugService; + + @Mock + MemberService memberService; + + @DisplayName("벌레를 조회한다.") + @Test + void get_bug_success() { + // given + Long memberId = 1L; + Member member = MemberFixture.member(); + given(memberService.getById(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()); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/BugTest.java b/src/test/java/com/moabam/api/domain/entity/BugTest.java new file mode 100644 index 00000000..c356fd58 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/BugTest.java @@ -0,0 +1,30 @@ +package com.moabam.api.domain.entity; + +import static 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; + +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 이상이어야 합니다."); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index dbc695fc..8255d843 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -23,6 +23,7 @@ void create_member_success() { .socialId(socialId) .nickname(nickname) .profileImage(profileImage) + .bug(Bug.builder().build()) .build()); } @@ -35,14 +36,15 @@ void create_member_noImage_success() { .socialId(socialId) .nickname(nickname) .profileImage(null) + .bug(Bug.builder().build()) .build(); assertAll( () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.PROFILE_URL), () -> assertThat(member.getRole()).isEqualTo(Role.USER), - () -> assertThat(member.getNightBug()).isZero(), - () -> assertThat(member.getGoldenBug()).isZero(), - () -> assertThat(member.getMorningBug()).isZero(), + () -> 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(), 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..4bfedb04 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -0,0 +1,55 @@ +package com.moabam.api.presentation; + +import static java.nio.charset.StandardCharsets.*; +import static org.mockito.BDDMockito.*; +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.assertj.core.api.Assertions; +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.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 com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.BugService; +import com.moabam.api.dto.BugMapper; +import com.moabam.api.dto.BugResponse; +import com.moabam.fixture.BugFixture; + +@SpringBootTest +@AutoConfigureMockMvc +class BugControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + BugService bugService; + + @DisplayName("벌레를 조회한다.") + @Test + void get_bug_success() throws Exception { + // given + Long memberId = 1L; + BugResponse expected = BugMapper.toBugResponse(BugFixture.bug()); + given(bugService.getBug(memberId)).willReturn(expected); + + // expected + String content = mockMvc.perform(get("/bugs")) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + BugResponse actual = objectMapper.readValue(content, BugResponse.class); + Assertions.assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/com/moabam/fixture/BugFixture.java b/src/test/java/com/moabam/fixture/BugFixture.java new file mode 100644 index 00000000..8d2b8703 --- /dev/null +++ b/src/test/java/com/moabam/fixture/BugFixture.java @@ -0,0 +1,18 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Bug; + +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 Bug bug() { + return Bug.builder() + .morningBug(MORNING_BUG) + .nightBug(NIGHT_BUG) + .goldenBug(GOLDEN_BUG) + .build(); + } +} diff --git a/src/test/java/com/moabam/fixture/MemberFixture.java b/src/test/java/com/moabam/fixture/MemberFixture.java new file mode 100644 index 00000000..02c5d84a --- /dev/null +++ b/src/test/java/com/moabam/fixture/MemberFixture.java @@ -0,0 +1,19 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Member; + +public final class MemberFixture { + + public static final String SOCIAL_ID = "test123"; + public static final String NICKNAME = "모아밤"; + public static final String PROFILE_IMAGE = "/profile/moabam.png"; + + public static Member member() { + return Member.builder() + .socialId(SOCIAL_ID) + .nickname(NICKNAME) + .profileImage(PROFILE_IMAGE) + .bug(BugFixture.bug()) + .build(); + } +} From e1484c7f6587f0951b9ac8a069577e2597e61605 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Wed, 1 Nov 2023 23:49:27 +0900 Subject: [PATCH 010/185] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#22)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: SQL syntax 오류 수정 * feat: 상품 엔티티 생성 * feat: 상품 목록 조회 API 구현 * test: 상품 목록 조회 테스트 * style: return 전 줄바꿈 추가 --- .../api/application/ProductService.java | 27 +++++++ .../com/moabam/api/domain/entity/Product.java | 74 +++++++++++++++++++ .../api/domain/entity/enums/ProductType.java | 6 ++ .../domain/repository/ProductRepository.java | 9 +++ .../com/moabam/api/dto/ProductMapper.java | 30 ++++++++ .../com/moabam/api/dto/ProductResponse.java | 14 ++++ .../com/moabam/api/dto/ProductsResponse.java | 12 +++ .../api/presentation/ProductController.java | 24 ++++++ .../global/error/model/ErrorMessage.java | 4 +- src/main/resources/config | 2 +- .../api/application/ProductServiceTest.java | 48 ++++++++++++ .../moabam/api/domain/entity/ProductTest.java | 36 +++++++++ .../presentation/ProductControllerTest.java | 59 +++++++++++++++ .../com/moabam/fixture/ProductFixture.java | 20 +++++ 14 files changed, 363 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ProductService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Product.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ProductType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ProductRepository.java create mode 100644 src/main/java/com/moabam/api/dto/ProductMapper.java create mode 100644 src/main/java/com/moabam/api/dto/ProductResponse.java create mode 100644 src/main/java/com/moabam/api/dto/ProductsResponse.java create mode 100644 src/main/java/com/moabam/api/presentation/ProductController.java create mode 100644 src/test/java/com/moabam/api/application/ProductServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/ProductTest.java create mode 100644 src/test/java/com/moabam/api/presentation/ProductControllerTest.java create mode 100644 src/test/java/com/moabam/fixture/ProductFixture.java diff --git a/src/main/java/com/moabam/api/application/ProductService.java b/src/main/java/com/moabam/api/application/ProductService.java new file mode 100644 index 00000000..677d840d --- /dev/null +++ b/src/main/java/com/moabam/api/application/ProductService.java @@ -0,0 +1,27 @@ +package com.moabam.api.application; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Product; +import com.moabam.api.domain.repository.ProductRepository; +import com.moabam.api.dto.ProductMapper; +import com.moabam.api.dto.ProductsResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ProductService { + + private final ProductRepository productRepository; + + public ProductsResponse getProducts() { + List products = productRepository.findAll(); + + return ProductMapper.toProductsResponse(products); + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Product.java b/src/main/java/com/moabam/api/domain/entity/Product.java new file mode 100644 index 00000000..fad1b7d4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Product.java @@ -0,0 +1,74 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.entity.enums.ProductType; +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/entity/enums/ProductType.java b/src/main/java/com/moabam/api/domain/entity/enums/ProductType.java new file mode 100644 index 00000000..381b00a1 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/ProductType.java @@ -0,0 +1,6 @@ +package com.moabam.api.domain.entity.enums; + +public enum ProductType { + + BUG; +} diff --git a/src/main/java/com/moabam/api/domain/repository/ProductRepository.java b/src/main/java/com/moabam/api/domain/repository/ProductRepository.java new file mode 100644 index 00000000..17c91398 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ProductRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Product; + +public interface ProductRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/dto/ProductMapper.java b/src/main/java/com/moabam/api/dto/ProductMapper.java new file mode 100644 index 00000000..0851d490 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ProductMapper.java @@ -0,0 +1,30 @@ +package com.moabam.api.dto; + +import java.util.List; + +import com.moabam.api.domain.entity.Product; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PROTECTED) +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(products.stream() + .map(ProductMapper::toProductResponse) + .toList()) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/dto/ProductResponse.java b/src/main/java/com/moabam/api/dto/ProductResponse.java new file mode 100644 index 00000000..547f7396 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ProductResponse.java @@ -0,0 +1,14 @@ +package com.moabam.api.dto; + +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/ProductsResponse.java b/src/main/java/com/moabam/api/dto/ProductsResponse.java new file mode 100644 index 00000000..6f69956c --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ProductsResponse.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record ProductsResponse( + List products +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/ProductController.java b/src/main/java/com/moabam/api/presentation/ProductController.java new file mode 100644 index 00000000..c2231d5b --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/ProductController.java @@ -0,0 +1,24 @@ +package com.moabam.api.presentation; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.ProductService; +import com.moabam.api.dto.ProductsResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/products") +@RequiredArgsConstructor +public class ProductController { + + private final ProductService productService; + + @GetMapping + public ResponseEntity getProducts() { + return ResponseEntity.ok(productService.getProducts()); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 1e381ef0..522e1dee 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -17,7 +17,9 @@ public enum ErrorMessage { MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), - INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."); + INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), + INVALID_PRICE("가격은 0 이상이어야 합니다."), + INVALID_QUANTITY("수량은 1 이상이어야 합니다."); private final String message; } diff --git a/src/main/resources/config b/src/main/resources/config index 8bc59e64..90404393 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 8bc59e6455ce1220e00acf676849951cbd935373 +Subproject commit 90404393aafb50e5650b81f6b67b69adc825e938 diff --git a/src/test/java/com/moabam/api/application/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java new file mode 100644 index 00000000..f3445bd3 --- /dev/null +++ b/src/test/java/com/moabam/api/application/ProductServiceTest.java @@ -0,0 +1,48 @@ +package com.moabam.api.application; + +import static com.moabam.fixture.ProductFixture.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +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.domain.entity.Product; +import com.moabam.api.domain.repository.ProductRepository; +import com.moabam.api.dto.ProductResponse; +import com.moabam.api.dto.ProductsResponse; + +@ExtendWith(MockitoExtension.class) +class ProductServiceTest { + + @InjectMocks + ProductService productService; + + @Mock + ProductRepository productRepository; + + @DisplayName("상품 목록을 조회한다.") + @Test + void get_products_success() { + // given + Product product1 = bugProduct(); + Product product2 = bugProduct(); + given(productRepository.findAll()).willReturn(List.of(product1, product2)); + + // when + ProductsResponse response = productService.getProducts(); + + // then + List productNames = response.products().stream() + .map(ProductResponse::name) + .toList(); + assertThat(response.products()).hasSize(2); + assertThat(productNames).containsOnly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/ProductTest.java b/src/test/java/com/moabam/api/domain/entity/ProductTest.java new file mode 100644 index 00000000..34780ecd --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/ProductTest.java @@ -0,0 +1,36 @@ +package com.moabam.api.domain.entity; + +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("X10") + .price(-10); + + assertThatThrownBy(productBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("가격은 0 이상이어야 합니다."); + } + + @DisplayName("상품량이 1 보다 작으면 예외가 발생한다.") + @Test + void validate_quantity_exception() { + Product.ProductBuilder productBuilder = Product.builder() + .name("X10") + .price(1000) + .quantity(-1); + + assertThatThrownBy(productBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("수량은 1 이상이어야 합니다."); + } +} diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java new file mode 100644 index 00000000..f18b3236 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -0,0 +1,59 @@ +package com.moabam.api.presentation; + +import static java.nio.charset.StandardCharsets.*; +import static org.mockito.BDDMockito.*; +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.assertj.core.api.Assertions; +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.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 com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.ProductService; +import com.moabam.api.domain.entity.Product; +import com.moabam.api.dto.ProductMapper; +import com.moabam.api.dto.ProductsResponse; +import com.moabam.fixture.ProductFixture; + +@SpringBootTest +@AutoConfigureMockMvc +class ProductControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + ProductService productService; + + @DisplayName("상품 목록을 조회한다.") + @Test + void get_products_success() throws Exception { + // given + Product product1 = ProductFixture.bugProduct(); + Product product2 = ProductFixture.bugProduct(); + ProductsResponse expected = ProductMapper.toProductsResponse(List.of(product1, product2)); + given(productService.getProducts()).willReturn(expected); + + // when & then + String content = mockMvc.perform(get("/products")) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + ProductsResponse actual = objectMapper.readValue(content, ProductsResponse.class); + Assertions.assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/com/moabam/fixture/ProductFixture.java b/src/test/java/com/moabam/fixture/ProductFixture.java new file mode 100644 index 00000000..91170206 --- /dev/null +++ b/src/test/java/com/moabam/fixture/ProductFixture.java @@ -0,0 +1,20 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Product; +import com.moabam.api.domain.entity.enums.ProductType; + +public class ProductFixture { + + public static final String BUG_PRODUCT_NAME = "X10"; + 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(); + } +} From 7c252fe3e91035a35fa918e75a69bb83ae52699e Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 2 Nov 2023 16:30:21 +0900 Subject: [PATCH 011/185] =?UTF-8?q?feat:=20Authorization=20Server=EB=A1=9C?= =?UTF-8?q?=20=EB=B6=80=ED=84=B0=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 --- .../application/AuthenticationService.java | 57 +++++-- ...uth2AuthorizationServerRequestService.java | 55 +++++++ .../com/moabam/api/domain/entity/Member.java | 24 ++- .../api/dto/AuthorizationCodeResponse.java | 10 ++ .../api/dto/AuthorizationTokenRequest.java | 24 +++ .../api/dto/AuthorizationTokenResponse.java | 15 ++ .../java/com/moabam/api/dto/OAuthMapper.java | 10 ++ .../api/presentation/MemberController.java | 7 + .../common/util/OAuthParameterNames.java | 2 + .../com/moabam/global/config/OAuthConfig.java | 4 +- .../global/error/model/ErrorMessage.java | 6 +- .../AuthenticationServiceTest.java | 114 ++++++++----- ...AuthorizationServerRequestServiceTest.java | 150 ++++++++++++++++++ .../moabam/api/domain/entity/MemberTest.java | 40 +++++ .../presentation/MemberControllerTest.java | 89 +++++++++-- .../AuthorizationTokenResponseFixture.java | 19 +++ src/test/resources/application-test.yml | 6 +- 17 files changed, 562 insertions(+), 70 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java create mode 100644 src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java create mode 100644 src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 7c407afc..11397a03 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -2,13 +2,16 @@ import static com.moabam.global.common.util.OAuthParameterNames.*; -import java.io.IOException; - -import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenRequest; +import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; @@ -23,8 +26,9 @@ public class AuthenticationService { private final OAuthConfig oAuthConfig; + private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; - private String getAuthorizaionCodeUri() { + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } @@ -44,14 +48,45 @@ private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCod return authorizationCodeUri.toUriString(); } - public void redirectToLoginPage(HttpServletResponse httpServletResponse) { - String authorizationCodeUri = getAuthorizaionCodeUri(); + private void validAuthorizationGrant(String code) { + if (code == null) { + throw new BadRequestException(ErrorMessage.GRANT_FAILED); + } + } + + private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code) { + AuthorizationTokenRequest authorizationTokenRequest = OAuthMapper.toAuthorizationTokenRequest(oAuthConfig, + code); + MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest); + ResponseEntity authorizationTokenResponse = + oauth2AuthorizationServerRequestService.requestAuthorizationServer(oAuthConfig.provider().tokenUri(), + uriParams); + + return authorizationTokenResponse.getBody(); + } - try { - httpServletResponse.setContentType(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); - httpServletResponse.sendRedirect(authorizationCodeUri); - } catch (IOException e) { - throw new BadRequestException(ErrorMessage.REQUEST_FAILD); + 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 redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizationCodeUri(); + oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); + } + + public void requestToken(AuthorizationCodeResponse authorizationCodeResponse) { + validAuthorizationGrant(authorizationCodeResponse.code()); + issueTokenToAuthorizationServer(authorizationCodeResponse.code()); + // TODO 발급한 토큰으로 사용자의 정보 얻어와야함 : 프로필 & 닉네임 } } diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java new file mode 100644 index 00000000..339f1503 --- /dev/null +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -0,0 +1,55 @@ +package com.moabam.api.application; + +import java.io.IOException; + +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.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; + +@Service +public class OAuth2AuthorizationServerRequestService { + + private final RestTemplate restTemplate; + + public OAuth2AuthorizationServerRequestService() { + restTemplate = new RestTemplate(); + } + + 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); + + ResponseEntity authorizationTokenResponse = restTemplate.exchange(tokenUri, + HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + + if (authorizationTokenResponse.getStatusCode().isError()) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + + return authorizationTokenResponse; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index f7108bac..1d51cb4d 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -29,7 +29,7 @@ @Entity @Getter @Table(name = "member") -@SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where participant_id = ?") +@SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where id = ?") @Where(clause = "deleted_at IS NOT NULL") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseTimeEntity { @@ -72,7 +72,7 @@ public class Member extends BaseTimeEntity { @Enumerated(EnumType.STRING) @Column(name = "role", nullable = false) - @ColumnDefault("USER") + @ColumnDefault("'USER'") private Role role; @Column(name = "deleted_at") @@ -87,4 +87,24 @@ private Member(Long id, String socialId, String nickname, String profileImage, B this.bug = requireNonNull(bug); this.role = Role.USER; } + + public void enterMorningRoom() { + currentMorningCount++; + } + + public void enterNightRoom() { + currentNightCount++; + } + + public void exitMorningRoom() { + if (currentMorningCount > 0) { + currentMorningCount--; + } + } + + public void exitNightRoom() { + if (currentMorningCount > 0) { + currentNightCount--; + } + } } diff --git a/src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java b/src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java new file mode 100644 index 00000000..da8c19c8 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java @@ -0,0 +1,10 @@ +package com.moabam.api.dto; + +public record AuthorizationCodeResponse( + String code, + String error, + String errorDescription, + String state +) { + +} diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java b/src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java new file mode 100644 index 00000000..17468c76 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java @@ -0,0 +1,24 @@ +package com.moabam.api.dto; + +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/AuthorizationTokenResponse.java b/src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java new file mode 100644 index 00000000..80a0d5c6 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto; + +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/OAuthMapper.java b/src/main/java/com/moabam/api/dto/OAuthMapper.java index 8b82483b..6550f32f 100644 --- a/src/main/java/com/moabam/api/dto/OAuthMapper.java +++ b/src/main/java/com/moabam/api/dto/OAuthMapper.java @@ -15,4 +15,14 @@ public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oA .scope(oAuthConfig.client().scope()) .build(); } + + public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig oAuthConfig, String code) { + return AuthorizationTokenRequest.builder() + .grantType(oAuthConfig.client().authorizationGrantType()) + .clientId(oAuthConfig.client().clientId()) + .redirectUri(oAuthConfig.provider().redirectUri()) + .code(code) + .clientSecret(oAuthConfig.client().clientSecret()) + .build(); + } } diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index fd2e730f..b2c60743 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -1,10 +1,12 @@ package com.moabam.api.presentation; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.AuthenticationService; +import com.moabam.api.dto.AuthorizationCodeResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -20,4 +22,9 @@ public class MemberController { public void socialLogin(HttpServletResponse httpServletResponse) { authenticationService.redirectToLoginPage(httpServletResponse); } + + @GetMapping("/login/kakao/oauth") + public void authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse) { + authenticationService.requestToken(authorizationCodeResponse); + } } diff --git a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java index efc65e59..05e26dea 100644 --- a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java +++ b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java @@ -11,4 +11,6 @@ public class OAuthParameterNames { public static final String CLIENT_ID = "client_id"; public static final String REDIRECT_URI = "redirect_uri"; public static final String SCOPE = "scope"; + public static final String GRANT_TYPE = "grant_type"; + public static final String CLIENT_SECRET = "client_secret"; } diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java index cff7f45b..c83b6a77 100644 --- a/src/main/java/com/moabam/global/config/OAuthConfig.java +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -13,6 +13,7 @@ public record OAuthConfig( public record Client( String provider, String clientId, + String clientSecret, String authorizationGrantType, List scope ) { @@ -21,7 +22,8 @@ public record Client( public record Provider( String authorizationUri, - String redirectUri + String redirectUri, + String tokenUri ) { } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 522e1dee..f874a1d3 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -8,13 +8,15 @@ public enum ErrorMessage { INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), - LOGIN_FAILED("로그인에 실패했습니다."), - REQUEST_FAILD("네트우크 접근 실패입니다."), + LOGIN_FAILED("로그인에 실패했습니다."), + REQUEST_FAILED("네트워크 접근 실패입니다."), + GRANT_FAILED("인가 코드 실패"), MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index 20fea0af..744b466f 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -3,9 +3,8 @@ import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.*; -import java.io.IOException; import java.util.List; import org.assertj.core.api.Assertions; @@ -14,26 +13,32 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; -import org.mockito.Mockito; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.MediaType; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; import com.moabam.api.dto.AuthorizationCodeRequest; +import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenRequest; +import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.global.common.util.GlobalConstant; +import com.moabam.fixture.AuthorizationTokenResponseFixture; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; -import jakarta.servlet.http.HttpServletResponse; - @ExtendWith(MockitoExtension.class) class AuthenticationServiceTest { @InjectMocks AuthenticationService authenticationService; + + @Mock + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + OAuthConfig oauthConfig; AuthenticationService noPropertyService; OAuthConfig noOAuthConfig; @@ -41,17 +46,17 @@ class AuthenticationServiceTest { @BeforeEach public void initParams() { oauthConfig = new OAuthConfig( - new OAuthConfig.Provider("https://authorization/url", "http://redirect/url"), - new OAuthConfig.Client("provider", "testtestetsttest", "authorization_code", + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url"), + new OAuthConfig.Client("provider", "testtestetsttest", "testtesttest", "authorization_code", List.of("profile_nickname", "profile_image")) ); ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); noOAuthConfig = new OAuthConfig( - new OAuthConfig.Provider(null, null), - new OAuthConfig.Client(null, null, null, null) + new OAuthConfig.Provider(null, null, null), + new OAuthConfig.Client(null, null, null, null, null) ); - noPropertyService = new AuthenticationService(noOAuthConfig); + noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService); } @@ -77,42 +82,79 @@ void authorization_code_request_mapping_success() { ); } - @DisplayName("인가코드 URI 생성 성공") + @DisplayName("redirect 로그인페이지 성공") @Test - void authorization_code_uri_generate_success() throws IOException { + void redirect_loginPage_success() { // given - String uri = "https://authorization/url?" - + "response_type=code&" - + "client_id=testtestetsttest&" - + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; - - MockHttpServletResponse mockHttpServletResponse = mockHttpServletResponse = new MockHttpServletResponse(); + MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); // when authenticationService.redirectToLoginPage(mockHttpServletResponse); // then - assertThat(mockHttpServletResponse.getContentType()) - .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); - assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); + verify(oAuth2AuthorizationServerRequestService).loginRequest(eq(mockHttpServletResponse), anyString()); } - @DisplayName("redirect 실패 테스트") + @DisplayName("인가코드 반환 실패") @Test - void redirect_fail_test() { - // given - HttpServletResponse mockHttpServletResponse = Mockito.mock(HttpServletResponse.class); + void authorization_grant_fail() { + // Given + AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse(null, "error", + "errorDescription", null); + + // When + Then + assertThatThrownBy(() -> authenticationService.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 = + AuthorizationTokenResponseFixture.authorizationTokenResponse(); + + // When + when(oAuth2AuthorizationServerRequestService.requestAuthorizationServer(anyString(), any())).thenReturn( + new ResponseEntity<>(authorizationTokenResponse, HttpStatus.OK)); + + // When + Then + assertThatNoException().isThrownBy(() -> authenticationService.requestToken(authorizationCodeResponse)); + } - try { - doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); + @DisplayName("토큰 요청 매퍼 실패 - code null") + @Test + void token_request_mapping_failBy_code() { + // When + Then + Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationTokenRequest(oauthConfig, null)) + .isInstanceOf(NullPointerException.class); + } - assertThatThrownBy(() -> { - // When + Then - authenticationService.redirectToLoginPage(mockHttpServletResponse); - }).isExactlyInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.REQUEST_FAILD.getMessage()); - } catch (Exception ignored) { + @DisplayName("토큰 요청 매퍼 실패 - config 에러") + @Test + void token_request_mapping_failBy_config() { + // When + Then + Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test")) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("토큰 요청 매퍼 성공") + @Test + void token_request_mapping_success() { + // Given + String code = "Test"; + AuthorizationTokenRequest authorizationTokenRequest = OAuthMapper.toAuthorizationTokenRequest(oauthConfig, + code); - } + // 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) + ); } } diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java new file mode 100644 index 00000000..305aa885 --- /dev/null +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -0,0 +1,150 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +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.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +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.RestTemplate; + +import com.moabam.api.dto.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; + + String uri = "https://authorization/url?" + + "response_type=code&" + + "client_id=testtestetsttest&" + + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; + + @BeforeEach + void initField() { + ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); + } + + @DisplayName("로그인 페이지 접근 요청") + @Nested + class LoginPage { + + @DisplayName("로그인 페이지 접근 요청 성공") + @Test + void authorization_code_uri_generate_success() throws IOException { + // 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_test() { + // given + HttpServletResponse mockHttpServletResponse = Mockito.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 toekn_issue_request_success() { + // given + String tokenUri = "test"; + MultiValueMap uriParams = new LinkedMultiValueMap<>(); + ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); + + // when + when(restTemplate.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)) + ).thenReturn(authorizationTokenResponse); + + // When + when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + + // Then + assertThatNoException().isThrownBy( + () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)); + } + + @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<>(); + + ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); + + when(restTemplate.exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)) + ).thenReturn(authorizationTokenResponse); + + // When + when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(code)); + + // Then + assertThatThrownBy( + () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.REQUEST_FAILED.getMessage()); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index 8255d843..1a947ac8 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -4,9 +4,11 @@ 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.entity.enums.Role; +import com.moabam.fixture.MemberFixture; import com.moabam.global.common.util.BaseImageUrl; class MemberTest { @@ -70,4 +72,42 @@ void create_member_failBy_nickname() { .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.enterMorningRoom(); + + int beforeNightCount = member.getCurrentNightCount(); + member.enterNightRoom(); + + // 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.exitMorningRoom(); + member.exitNightRoom(); + + // then + assertThat(member.getCurrentMorningCount()).isZero(); + assertThat(member.getCurrentMorningCount()).isZero(); + } + } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index cd4da732..39742fae 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,34 +1,50 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.util.OAuthParameterNames.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static com.moabam.global.common.util.OAuthParameterNames.CLIENT_ID; +import static com.moabam.global.common.util.OAuthParameterNames.CLIENT_SECRET; +import static com.moabam.global.common.util.OAuthParameterNames.CODE; +import static com.moabam.global.common.util.OAuthParameterNames.GRANT_TYPE; +import static com.moabam.global.common.util.OAuthParameterNames.REDIRECT_URI; +import static com.moabam.global.common.util.OAuthParameterNames.RESPONSE_TYPE; +import static com.moabam.global.common.util.OAuthParameterNames.SCOPE; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +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.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.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +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.AuthenticationService; +import com.moabam.api.application.OAuth2AuthorizationServerRequestService; +import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.fixture.AuthorizationTokenResponseFixture; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; -@SpringBootTest( - properties = { - "oauth2.provider.authorization-uri=https://kauth.kakao.com/oauth/authorize", - "oauth2.provider.redirect-uri=http://localhost:8080/members/login/kakao/oauth", - "oauth2.client.client-id=testtesttesttesttesttesttesttesttest", - "oauth2.client.authorization-grant-type=authorization_code", - "oauth2.client.scope=profile_nickname,profile_image" - } -) +@SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class MemberControllerTest { @Autowired @@ -38,11 +54,27 @@ class MemberControllerTest { ObjectMapper objectMapper; @Autowired - AuthenticationService authenticationService; + OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; @Autowired OAuthConfig oAuthConfig; + static RestTemplate restTemplate; + + MockRestServiceServer mockRestServiceServer; + + @BeforeAll + static void allSetUp() { + restTemplate = new RestTemplate(); + } + + @BeforeEach + void setUp() { + // TODO 추후 RestTemplate -> REstTemplateBuilder & Bean등록하여 테스트 코드도 일부 변경됨 + ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); + mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + } + @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") @Test void authorization_code_request_success() throws Exception { @@ -61,6 +93,33 @@ void authorization_code_request_success() throws Exception { result.andExpect(status().is3xxRedirection()) .andExpect(header().string("Content-type", MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) - .andExpect(redirectedUrl(uri)); + .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); + } + + @DisplayName("Authorization Server에 토큰 발급 요청") + @Test + void authorization_token_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()); + + String response = objectMapper.writeValueAsString( + AuthorizationTokenResponseFixture.authorizationTokenResponse()); + AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse("test", null, null, null); + + 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)); + + // expected + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isOk()); } } diff --git a/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java b/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java new file mode 100644 index 00000000..ecfa1506 --- /dev/null +++ b/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java @@ -0,0 +1,19 @@ +package com.moabam.fixture; + +import com.moabam.api.dto.AuthorizationTokenResponse; + +public class AuthorizationTokenResponseFixture { + + 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 AuthorizationTokenResponse authorizationTokenResponse() { + return new AuthorizationTokenResponse(tokenType, accessToken, idToken, + expiresin, refreshToken, refreshTokenExpiresIn, scope); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index b149d76f..94864159 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -9,6 +9,6 @@ oauth2: - profile_image provider: - authorization_uri: https://test.com/test/test - redirect_uri: http://test:8080/test - token_uri: https://test.test.com/test/test + authorization_uri: https://authorization.com/test/test + redirect_uri: http://redirect:8080/test + token_uri: https://token.com/test/test From 8177609cfcc041d42ce3b90d21c9e57588d24c3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Fri, 3 Nov 2023 15:35:11 +0900 Subject: [PATCH 012/185] =?UTF-8?q?feat:=20=EC=BD=95=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=95=8C=EB=A6=BC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Redis 초기 설정 * feat: FCM 초기 설정 및 예외처리 * feat: 콕 찌르기 기능 서비스 및 레포지토리 구현 * fix: Redis Reposi 추상 클래스 제거 및 테스트 Profile 변경 * test: StringRedisRepository 테스트 * test: NotificationRepository 테스트 * feat: NullPointerException 예외 핸들링 처리 * test: NotificationService 테스트 * refacotr: PostConstruct를 Bean으로 변경 * refactor : 테스트 코드 Profile 변경 * fix: redis 테스트 삭제 * fix : Redis 테스트 클래스 삭제 * fix : Member Role Default 문제 해결 * fix: firebase config 경로 변경 * fix: 에러 찾기 위한 로그 설정 * fix: CI가 서브모듈 경로를 못찾는 에러 해결 * test: Redis Repository 테스트 및 로그 삭제 * style: 메서드명 및 줄바꿈 설정 * refactor: 콕 찌르기 알림 저장 시, 키값 및 만료시간 변경 * refactor: 리뷰 코드 수정 --- .github/workflows/ci.yml | 74 +++++----- build.gradle | 6 + .../api/application/NotificationService.java | 50 +++++++ .../repository/NotificationRepository.java | 52 +++++++ .../moabam/api/dto/NotificationMapper.java | 28 ++++ .../global/common/annotation/MemberTest.java | 8 ++ .../repository/StringRedisRepository.java | 39 +++++ .../global/common/util/GlobalConstant.java | 7 + .../com/moabam/global/config/FcmConfig.java | 46 ++++++ .../com/moabam/global/config/RedisConfig.java | 34 +++++ .../global/error/exception/FcmException.java | 10 ++ .../error/handler/GlobalExceptionHandler.java | 17 ++- .../global/error/model/ErrorMessage.java | 6 +- src/main/resources/config | 2 +- .../moabam/MoabamServerApplicationTests.java | 5 - .../application/NotificationServiceTest.java | 83 +++++++++++ .../NotificationRepositoryTest.java | 133 ++++++++++++++++++ .../api/presentation/BugControllerTest.java | 2 + .../presentation/ProductControllerTest.java | 2 + .../repository/StringRedisRepositoryTest.java | 74 ++++++++++ src/test/resources/application-test.yml | 9 ++ 21 files changed, 642 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/NotificationService.java create mode 100644 src/main/java/com/moabam/api/domain/repository/NotificationRepository.java create mode 100644 src/main/java/com/moabam/api/dto/NotificationMapper.java create mode 100644 src/main/java/com/moabam/global/common/annotation/MemberTest.java create mode 100644 src/main/java/com/moabam/global/common/repository/StringRedisRepository.java create mode 100644 src/main/java/com/moabam/global/config/FcmConfig.java create mode 100644 src/main/java/com/moabam/global/config/RedisConfig.java create mode 100644 src/main/java/com/moabam/global/error/exception/FcmException.java create mode 100644 src/test/java/com/moabam/api/application/NotificationServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java create mode 100644 src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3109497d..54d5168f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,46 +1,48 @@ 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 + 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: 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 캐싱 + 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: 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: 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 }} + - name: 빌드 및 분석 + run: ./gradlew build jacocoTestReport sonar --info --stacktrace + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SONAR_TOKEN: ${{ secrets.SONAR_CLOUD_TOKEN }} diff --git a/build.gradle b/build.gradle index 0943c572..26a9be58 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,12 @@ dependencies { // Configuration Binding annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" + + // Redis + implementation 'org.springframework.boot:spring-boot-starter-data-redis' + + // Firebase Admin + implementation 'com.google.firebase:firebase-admin:9.2.0' } tasks.named('test') { diff --git a/src/main/java/com/moabam/api/application/NotificationService.java b/src/main/java/com/moabam/api/application/NotificationService.java new file mode 100644 index 00000000..797ae048 --- /dev/null +++ b/src/main/java/com/moabam/api/application/NotificationService.java @@ -0,0 +1,50 @@ +package com.moabam.api.application; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.dto.NotificationMapper; +import com.moabam.global.common.annotation.MemberTest; +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 final FirebaseMessaging firebaseMessaging; + private final NotificationRepository notificationRepository; + + @Transactional + public void sendKnockNotification(MemberTest member, Long targetId, Long roomId) { + validateFcmToken(targetId); + validateConflictKnockNotification(member.memberId(), targetId, roomId); + + String fcmToken = notificationRepository.findFcmTokenByMemberId(targetId); + Notification notification = NotificationMapper.toKnockNotificationEntity(member.nickname()); + Message message = NotificationMapper.toMessageEntity(notification, fcmToken); + + firebaseMessaging.sendAsync(message); + notificationRepository.saveKnockNotification(member.memberId(), targetId, roomId); + } + + private void validateFcmToken(Long memberId) { + if (!notificationRepository.existsFcmTokenByMemberId(memberId)) { + throw new NotFoundException(ErrorMessage.FCM_TOKEN_NOT_FOUND); + } + } + + private void validateConflictKnockNotification(Long memberId, Long targetId, Long roomId) { + if (notificationRepository.existsKnockByMemberId(memberId, targetId, roomId)) { + throw new ConflictException(ErrorMessage.KNOCK_CONFLICT); + } + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java new file mode 100644 index 00000000..ce705d68 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java @@ -0,0 +1,52 @@ +package com.moabam.api.domain.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.global.common.repository.StringRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class NotificationRepository { + + private final StringRedisRepository stringRedisRepository; + + // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. + public void saveFcmToken(Long key, String value) { + stringRedisRepository.save( + String.valueOf(requireNonNull(key)), + requireNonNull(value), + requireNonNull(Duration.ofDays(EXPIRE_FCM_TOKEN)) + ); + } + + public void saveKnockNotification(Long memberId, Long targetId, Long roomId) { + String key = requireNonNull(roomId) + UNDER_BAR + requireNonNull(memberId) + TO + requireNonNull(targetId); + stringRedisRepository.save(key, BLANK, requireNonNull(Duration.ofHours(EXPIRE_KNOCK))); + } + + // TODO : 세연님 로그아웃 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 삭제하시면 됩니다. + public void deleteFcmTokenByMemberId(Long memberId) { + stringRedisRepository.delete(String.valueOf(requireNonNull(memberId))); + } + + public String findFcmTokenByMemberId(Long memberId) { + return stringRedisRepository.get(String.valueOf(requireNonNull(memberId))); + } + + public boolean existsFcmTokenByMemberId(Long memberId) { + return stringRedisRepository.hasKey(String.valueOf(requireNonNull(memberId))); + } + + public boolean existsKnockByMemberId(Long memberId, Long targetId, Long roomId) { + String key = requireNonNull(roomId) + UNDER_BAR + requireNonNull(memberId) + TO + requireNonNull(targetId); + + return stringRedisRepository.hasKey(key); + } +} diff --git a/src/main/java/com/moabam/api/dto/NotificationMapper.java b/src/main/java/com/moabam/api/dto/NotificationMapper.java new file mode 100644 index 00000000..f51016be --- /dev/null +++ b/src/main/java/com/moabam/api/dto/NotificationMapper.java @@ -0,0 +1,28 @@ +package com.moabam.api.dto; + +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class NotificationMapper { + + private static final String TITLE = "모아밤"; + private static final String KNOCK_BODY = "님이 콕 찔렀습니다."; + + public static Notification toKnockNotificationEntity(String nickname) { + return Notification.builder() + .setTitle(TITLE) + .setBody(nickname + KNOCK_BODY) + .build(); + } + + public static Message toMessageEntity(Notification notification, String fcmToken) { + return Message.builder() + .setNotification(notification) + .setToken(fcmToken) + .build(); + } +} diff --git a/src/main/java/com/moabam/global/common/annotation/MemberTest.java b/src/main/java/com/moabam/global/common/annotation/MemberTest.java new file mode 100644 index 00000000..a1449cba --- /dev/null +++ b/src/main/java/com/moabam/global/common/annotation/MemberTest.java @@ -0,0 +1,8 @@ +package com.moabam.global.common.annotation; + +public record MemberTest( + Long memberId, + String nickname +) { + +} diff --git a/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java b/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java new file mode 100644 index 00000000..3677942c --- /dev/null +++ b/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java @@ -0,0 +1,39 @@ +package com.moabam.global.common.repository; + +import java.time.Duration; + +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StringRedisRepository { + + private final StringRedisTemplate stringRedisTemplate; + + @Transactional + public void save(String key, String value, Duration timeout) { + stringRedisTemplate + .opsForValue() + .set(key, value, timeout); + } + + @Transactional + public void delete(String key) { + stringRedisTemplate.delete(key); + } + + public String get(String key) { + return stringRedisTemplate + .opsForValue() + .get(key); + } + + public Boolean hasKey(String email) { + return stringRedisTemplate.hasKey(email); + } +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 5b87cd58..a74b791d 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -6,6 +6,13 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class GlobalConstant { + public static final String BLANK = ""; public static final String COMMA = ","; + public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; + + public static final String TO = "_TO_"; + public static final long EXPIRE_KNOCK = 12; + public static final long EXPIRE_FCM_TOKEN = 60; + public static final String FIREBASE_PATH = "config/moabam-firebase.json"; } 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..7c95478d --- /dev/null +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -0,0 +1,46 @@ +package com.moabam.global.config; + +import static com.moabam.global.common.util.GlobalConstant.*; + +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 org.springframework.scheduling.annotation.EnableScheduling; + +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 +@EnableScheduling +public class FcmConfig { + + @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.FCM_INIT_FAILED); + } + } +} 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..d4d484c2 --- /dev/null +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -0,0 +1,34 @@ +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.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +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 StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); + stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); + stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); + stringRedisTemplate.setConnectionFactory(redisConnectionFactory); + + return stringRedisTemplate; + } +} 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/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 2653c8d5..2dc1f65d 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -13,6 +13,7 @@ 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; @@ -53,10 +54,22 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio return new ErrorResponse(moabamException.getMessage(), null); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(FcmException.class) + protected ErrorResponse handleFcmException(MoabamException moabamException) { + return new ErrorResponse(moabamException.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) - public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException moabamException) { - List fieldErrors = moabamException.getBindingResult().getFieldErrors(); + protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException exception) { + List fieldErrors = exception.getBindingResult().getFieldErrors(); Map validation = new HashMap<>(); for (FieldError fieldError : fieldErrors) { diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index f874a1d3..b4302eeb 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -21,7 +21,11 @@ public enum ErrorMessage { INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), INVALID_PRICE("가격은 0 이상이어야 합니다."), - INVALID_QUANTITY("수량은 1 이상이어야 합니다."); + INVALID_QUANTITY("수량은 1 이상이어야 합니다."), + + FCM_INIT_FAILED("파이어베이스 설정을 실패했습니다."), + FCM_TOKEN_NOT_FOUND("해당 유저는 접속 중이 아닙니다."), + KNOCK_CONFLICT("이미 콕 알림을 보낸 대상입니다."); private final String message; } diff --git a/src/main/resources/config b/src/main/resources/config index 90404393..ab594df9 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 90404393aafb50e5650b81f6b67b69adc825e938 +Subproject commit ab594df9fcbf13159da3bb2fbb57d792b3f9f0f9 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/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java new file mode 100644 index 00000000..e21a1750 --- /dev/null +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -0,0 +1,83 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.global.common.annotation.MemberTest; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +@ExtendWith(MockitoExtension.class) +class NotificationServiceTest { + + @InjectMocks + private NotificationService notificationService; + + @Mock + private NotificationRepository notificationRepository; + + @Mock + private FirebaseMessaging firebaseMessaging; + + private MemberTest memberTest; + + @BeforeEach + void setUp() { + memberTest = new MemberTest(2L, "nickname"); + } + + @DisplayName("성공적으로 상대를 콕 찔렀을 때, - Void") + @Test + void notificationService_sendKnockNotification() { + // Given + given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); + given(notificationRepository.existsKnockByMemberId(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); + given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + + // When + notificationService.sendKnockNotification(memberTest, 2L, 1L); + + // Then + verify(firebaseMessaging).sendAsync(any(Message.class)); + verify(notificationRepository).saveKnockNotification(any(Long.class), any(Long.class), any(Long.class)); + } + + @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않을 때, - NotFoundException") + @Test + void notificationService_sendKnockNotification_NotFoundException() { + // Given + given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(false); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.FCM_TOKEN_NOT_FOUND.getMessage()); + } + + @DisplayName("콕 찌를 상대가 이미 찌른 상대일 때, - ConflictException") + @Test + void notificationService_sendKnockNotification_ConflictException() { + // Given + given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); + given(notificationRepository.existsKnockByMemberId(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(true); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.KNOCK_CONFLICT.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java new file mode 100644 index 00000000..87c1c5c7 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java @@ -0,0 +1,133 @@ +package com.moabam.api.domain.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.global.common.repository.StringRedisRepository; + +@ExtendWith(MockitoExtension.class) +class NotificationRepositoryTest { + + @InjectMocks + private NotificationRepository notificationRepository; + + @Mock + private StringRedisRepository stringRedisRepository; + + @DisplayName("FCM 토큰이 성공적으로 저장 될 때, - Void") + @Test + void notificationRepository_saveFcmToken() { + // When + notificationRepository.saveFcmToken(1L, "value1"); + + // Then + verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_save_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.saveFcmToken(null, "value")) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("콕 알림이 성공적으로 저장 될 때, - Void") + @Test + void notificationRepository_saveKnockNotification() { + // When + notificationRepository.saveKnockNotification(1L, 2L, 1L); + + // Then + verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("콕 알림 저장 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_saveKnockNotification_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.saveKnockNotification(null, 2L, 1L)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰이 성공적으로 삭제 될 때, - Void") + @Test + void notificationRepository_deleteFcmTokenByMemberId() { + // When + notificationRepository.deleteFcmTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).delete(any(String.class)); + } + + @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_deleteFcmTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.deleteFcmTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰을 성공적으로 조회할 때, - (String) FCM TOKEN") + @Test + void notificationRepository_findFcmTokenByMemberId() { + // When + notificationRepository.findFcmTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).get(any(String.class)); + } + + @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_findFcmTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.findFcmTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인 할 때, - Boolean") + @Test + void notificationRepository_existsFcmTokenByMemberId() { + // When + notificationRepository.existsFcmTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).hasKey(any(String.class)); + } + + @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_existsFcmTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.existsFcmTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("콕 알림 여부 체크를 정상적으로 확인할 때, - Boolean") + @Test + void notificationRepository_existsKnockByMemberId() { + // When + notificationRepository.existsKnockByMemberId(1L, 2L, 1L); + + // Then + verify(stringRedisRepository).hasKey(any(String.class)); + } + + @DisplayName("콕 알림 여부 체크 시, 필요한 값이 NULL 일 때, - NullPointerException") + @Test + void notificationRepository_existsKnockByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> notificationRepository.existsKnockByMemberId(null, 2L, 1L)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 4bfedb04..28881031 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -13,6 +13,7 @@ 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.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; @@ -23,6 +24,7 @@ @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class BugControllerTest { @Autowired diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index f18b3236..338ed65b 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -15,6 +15,7 @@ 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.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,6 +27,7 @@ @SpringBootTest @AutoConfigureMockMvc +@ActiveProfiles("test") class ProductControllerTest { @Autowired diff --git a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java new file mode 100644 index 00000000..b2f424fa --- /dev/null +++ b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java @@ -0,0 +1,74 @@ +package com.moabam.global.common.repository; + +import static org.mockito.BDDMockito.*; + +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.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +@ExtendWith(MockitoExtension.class) +class StringRedisRepositoryTest { + + @InjectMocks + private StringRedisRepository stringRedisRepository; + + @Mock + private StringRedisTemplate stringRedisTemplate; + + @Spy + private ValueOperations valueOperations; + + @DisplayName("레디스에 문자열 데이터가 성공적으로 저장될 때, - Void") + @Test + void string_redis_repository_save() { + // Given + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + + // When + stringRedisRepository.save("key1", "value", Duration.ofHours(1)); + + // Then + verify(stringRedisTemplate.opsForValue()).set(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("레디스의 특정 데이터가 성공적으로 삭제될 때, - Void") + @Test + void string_redis_repository_delete() { + // When + stringRedisRepository.delete("key2"); + + // Then + verify(stringRedisTemplate).delete(any(String.class)); + } + + @DisplayName("레디스의 특정 데이터가 성공적으로 조회될 때, - String(Value)") + @Test + void string_redis_repository_get() { + // Given + given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); + + // When + stringRedisRepository.get("key3"); + + // Then + verify(stringRedisTemplate.opsForValue()).get(any(String.class)); + } + + @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크할 때, - Boolean") + @Test + void string_redis_repository_hasKey() { + // When + stringRedisRepository.hasKey("not found key"); + + // Then + verify(stringRedisTemplate).hasKey(any(String.class)); + } +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 94864159..33df62ba 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -12,3 +12,12 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test + +# Spring +spring: + + # Redis + data: + redis: + host: 127.0.0.1 + port: 6379 From b3bd297a677e3a8b83cc8b3435893abd831ef82b Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Fri, 3 Nov 2023 16:43:08 +0900 Subject: [PATCH 013/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=B0=B8=EC=97=AC,?= =?UTF-8?q?=20=EB=82=98=EA=B0=80=EA=B8=B0=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Room, Participant, Routine, Certification 엔티티 생성 * feat: Room 엔티티 인증 시간 검증 로직 추가 * test: Room 엔티티 테스트 코드 작성 * refactor: Room 관련 엔티티 수정 * feat: 방 생성 기능 구현 * chore: DynamicQuery Jacoco 예외 추가 * test: 방 생성 테스트 코드 작성 * feat: 방 수정 기능 구현 * test: 방 수정 통합 테스트 작성 * refactor: Member 관련 파일 이동 * refactor: checkStyle에 맞춰서 변경 * test: 추가 테스트 코드 작성 * chore: Apache Commons Lang 의존성 추가 * feat: 방 참여 기능 구현 * test: 방 참여 기능 테스트 작성 * feat: 방 나가기 기능 구현 * chore: test yml JPA 로그 추가 * test: 방 참여, 나가기 일부 테스트 작성 * feat: 방 나가기 구현 마무리 * fix: Morning -> Night 수정 * test: 방 나가기 추가 테스트 코드 작성 * test: 방 나가기 추가 테스트 작성 * feat: 방 ID로 존재 확인 로직 추가 * refactor: 오타 수정 * fix: 테스트 실행 불가 해결 * fix: CI 오류 해결 * refactor: 코드 리뷰 반영 --- build.gradle | 3 + .../moabam/api/application/RoomService.java | 117 ++++- .../com/moabam/api/domain/entity/Member.java | 4 +- .../moabam/api/domain/entity/Participant.java | 10 +- .../com/moabam/api/domain/entity/Room.java | 14 +- .../ParticipantSearchRepository.java | 2 +- .../com/moabam/api/dto/EnterRoomRequest.java | 9 + .../api/presentation/RoomController.java | 14 + .../global/error/model/ErrorMessage.java | 4 + .../api/presentation/BugControllerTest.java | 2 - .../presentation/MemberControllerTest.java | 20 +- .../presentation/ProductControllerTest.java | 2 - .../api/presentation/RoomControllerTest.java | 482 ++++++++++++++++-- .../{application-test.yml => application.yml} | 25 +- 14 files changed, 641 insertions(+), 67 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/EnterRoomRequest.java rename src/test/resources/{application-test.yml => application.yml} (79%) diff --git a/build.gradle b/build.gradle index 26a9be58..8de7b745 100644 --- a/build.gradle +++ b/build.gradle @@ -55,6 +55,9 @@ dependencies { // 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' diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java index b16e6a06..5480ef16 100644 --- a/src/main/java/com/moabam/api/application/RoomService.java +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -1,22 +1,28 @@ package com.moabam.api.application; +import static com.moabam.api.domain.entity.enums.RoomType.*; import static com.moabam.global.error.model.ErrorMessage.*; 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.domain.entity.Member; import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.entity.enums.RoomType; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.api.domain.repository.RoomRepository; import com.moabam.api.domain.repository.RoutineRepository; import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; import com.moabam.api.dto.RoomMapper; +import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ForbiddenException; import com.moabam.global.error.exception.NotFoundException; @@ -31,6 +37,7 @@ public class RoomService { private final RoutineRepository routineRepository; private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; + private final MemberService memberService; @Transactional public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @@ -40,6 +47,7 @@ public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { .room(room) .memberId(memberId) .build(); + participant.enableManager(); roomRepository.save(room); routineRepository.saveAll(routines); @@ -48,18 +56,119 @@ public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @Transactional public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomRequest) { - // TODO: 추후에 별도 메서드로 뺄듯 - Participant participant = participantSearchRepository.findParticipant(roomId, memberId) - .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + Participant participant = getParticipant(memberId, roomId); if (!participant.isManager()) { throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); } - Room room = roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + Room room = getRoom(roomId); room.changeTitle(modifyRoomRequest.title()); room.changePassword(modifyRoomRequest.password()); room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); } + + @Transactional + public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequest) { + Room room = getRoom(roomId); + validateRoomEnter(memberId, enterRoomRequest.password(), room); + + room.increaseCurrentUserCount(); + increaseRoomCount(memberId, room.getRoomType()); + + Participant participant = Participant.builder() + .room(room) + .memberId(memberId) + .build(); + participantRepository.save(participant); + } + + @Transactional + public void exitRoom(Long memberId, Long roomId) { + Participant participant = getParticipant(memberId, roomId); + Room room = participant.getRoom(); + + if (participant.isManager() && room.getCurrentUserCount() != 1) { + throw new BadRequestException(ROOM_EXIT_MANAGER_FAIL); + } + + decreaseRoomCount(memberId, room.getRoomType()); + participant.removeRoom(); + participantRepository.flush(); + participantRepository.delete(participant); + + if (!participant.isManager()) { + room.decreaseCurrentUserCount(); + return; + } + + roomRepository.flush(); + roomRepository.delete(room); + } + + public void validateRoomById(Long roomId) { + if (!roomRepository.existsById(roomId)) { + throw new NotFoundException(ROOM_NOT_FOUND); + } + } + + private Participant getParticipant(Long memberId, Long roomId) { + return participantSearchRepository.findParticipant(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + } + + private Room getRoom(Long roomId) { + return roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + } + + private void validateRoomEnter(Long memberId, String requestPassword, Room room) { + if (!isEnterRoomAvailable(memberId, room.getRoomType())) { + throw new BadRequestException(MEMBER_ROOM_EXCEED); + } + + 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 boolean isEnterRoomAvailable(Long memberId, RoomType roomType) { + Member member = memberService.getById(memberId); + + if (roomType.equals(MORNING) && member.getCurrentMorningCount() >= 3) { + return false; + } + + if (roomType.equals(NIGHT) && member.getCurrentNightCount() >= 3) { + return false; + } + + return true; + } + + private void increaseRoomCount(Long memberId, RoomType roomType) { + Member member = memberService.getById(memberId); + + if (roomType.equals(MORNING)) { + member.enterMorningRoom(); + return; + } + + member.enterNightRoom(); + } + + private void decreaseRoomCount(Long memberId, RoomType roomType) { + Member member = memberService.getById(memberId); + + if (roomType.equals(MORNING)) { + member.exitMorningRoom(); + return; + } + + member.exitNightRoom(); + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index 1d51cb4d..a5c535c3 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -30,7 +30,7 @@ @Getter @Table(name = "member") @SQLDelete(sql = "UPDATE member SET deleted_at = CURRENT_TIMESTAMP where id = ?") -@Where(clause = "deleted_at IS NOT NULL") +@Where(clause = "deleted_at IS NULL") @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseTimeEntity { @@ -103,7 +103,7 @@ public void exitMorningRoom() { } public void exitNightRoom() { - if (currentMorningCount > 0) { + if (currentNightCount > 0) { currentNightCount--; } } diff --git a/src/main/java/com/moabam/api/domain/entity/Participant.java b/src/main/java/com/moabam/api/domain/entity/Participant.java index 733585cd..7c4442a8 100644 --- a/src/main/java/com/moabam/api/domain/entity/Participant.java +++ b/src/main/java/com/moabam/api/domain/entity/Participant.java @@ -33,7 +33,7 @@ public class Participant { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id", updatable = false, nullable = false) + @JoinColumn(name = "room_id", updatable = false) private Room room; @Column(name = "member_id", updatable = false, nullable = false) @@ -48,6 +48,9 @@ public class Participant { @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; @@ -68,4 +71,9 @@ public void enableManager() { public void updateCertifyCount() { this.certifyCount += 1; } + + public void removeRoom() { + this.deletedRoomTitle = this.room.getTitle(); + this.room = null; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/entity/Room.java index d4ad6c6b..acdd2896 100644 --- a/src/main/java/com/moabam/api/domain/entity/Room.java +++ b/src/main/java/com/moabam/api/domain/entity/Room.java @@ -43,7 +43,6 @@ public class Room extends BaseTimeEntity { @Column(name = "id") private Long id; - // TODO: 한글 10자도 맞나? @Column(name = "title", nullable = false, length = 30) private String title; @@ -66,7 +65,6 @@ public class Room extends BaseTimeEntity { @Column(name = "max_user_count", nullable = false) private int maxUserCount; - // TODO: 한글 길이 고려 @Column(name = "announcement", length = 255) private String announcement; @@ -111,6 +109,18 @@ public void changeMaxCount(int maxUserCount) { this.maxUserCount = maxUserCount; } + public void increaseCurrentUserCount() { + this.currentUserCount += 1; + + if (this.currentUserCount > this.maxUserCount) { + throw new BadRequestException(ROOM_MAX_USER_REACHED); + } + } + + public void decreaseCurrentUserCount() { + this.currentUserCount -= 1; + } + public void upgradeRoomImage(String roomImage) { this.roomImage = roomImage; } diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java index e80e81b9..9e3a01b3 100644 --- a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java @@ -18,7 +18,7 @@ public class ParticipantSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public Optional findParticipant(Long roomId, Long memberId) { + public Optional findParticipant(Long memberId, Long roomId) { return Optional.ofNullable( jpaQueryFactory.selectFrom(participant) .where( diff --git a/src/main/java/com/moabam/api/dto/EnterRoomRequest.java b/src/main/java/com/moabam/api/dto/EnterRoomRequest.java new file mode 100644 index 00000000..25da6d06 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/EnterRoomRequest.java @@ -0,0 +1,9 @@ +package com.moabam.api.dto; + +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/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index c0edc651..077f94f1 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,6 +1,7 @@ package com.moabam.api.presentation; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; @@ -11,6 +12,7 @@ import com.moabam.api.application.RoomService; import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; import jakarta.validation.Valid; @@ -35,4 +37,16 @@ public void modifyRoom(@Valid @RequestBody ModifyRoomRequest modifyRoomRequest, @PathVariable("roomId") Long roomId) { roomService.modifyRoom(1L, roomId, modifyRoomRequest); } + + @PostMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void enterRoom(@Valid @RequestBody EnterRoomRequest enterRoomRequest, @PathVariable("roomId") Long roomId) { + roomService.enterRoom(1L, roomId, enterRoomRequest); + } + + @DeleteMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public void exitRoom(@PathVariable("roomId") Long roomId) { + roomService.exitRoom(1L, roomId); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index b4302eeb..4421ceea 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -12,12 +12,16 @@ public enum ErrorMessage { 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("방의 인원수가 찼습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), GRANT_FAILED("인가 코드 실패"), MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), + MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), INVALID_PRICE("가격은 0 이상이어야 합니다."), diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 28881031..4bfedb04 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -13,7 +13,6 @@ 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.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; @@ -24,7 +23,6 @@ @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") class BugControllerTest { @Autowired diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 39742fae..114d5ba8 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,18 +1,10 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.util.OAuthParameterNames.CLIENT_ID; -import static com.moabam.global.common.util.OAuthParameterNames.CLIENT_SECRET; -import static com.moabam.global.common.util.OAuthParameterNames.CODE; -import static com.moabam.global.common.util.OAuthParameterNames.GRANT_TYPE; -import static com.moabam.global.common.util.OAuthParameterNames.REDIRECT_URI; -import static com.moabam.global.common.util.OAuthParameterNames.RESPONSE_TYPE; -import static com.moabam.global.common.util.OAuthParameterNames.SCOPE; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static com.moabam.global.common.util.OAuthParameterNames.*; +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 org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -23,7 +15,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; -import org.springframework.test.context.ActiveProfiles; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.match.MockRestRequestMatchers; @@ -44,7 +35,6 @@ @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") class MemberControllerTest { @Autowired diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index 338ed65b..f18b3236 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -15,7 +15,6 @@ 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.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; @@ -27,7 +26,6 @@ @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") class ProductControllerTest { @Autowired diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 55668783..36bce475 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -10,30 +10,37 @@ 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.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.RoomRepository; import com.moabam.api.domain.repository.RoutineRepository; import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; +import com.moabam.fixture.BugFixture; +import com.moabam.fixture.MemberFixture; @Transactional @SpringBootTest @AutoConfigureMockMvc -@ActiveProfiles("test") +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class RoomControllerTest { @Autowired @@ -51,6 +58,28 @@ class RoomControllerTest { @Autowired private ParticipantRepository participantRepository; + @Autowired + private MemberRepository memberRepository; + + Member member; + + @BeforeAll + void setUp() { + member = MemberFixture.member(); + memberRepository.save(member); + } + + @AfterEach + void cleanUp() { + while (member.getCurrentMorningCount() > 0) { + member.exitMorningRoom(); + } + + while (member.getCurrentNightCount() > 0) { + member.exitNightRoom(); + } + } + @DisplayName("비밀번호 없는 방 생성 성공") @Test void create_room_no_password_success() throws Exception { @@ -58,10 +87,8 @@ void create_room_no_password_success() throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -86,10 +113,8 @@ void create_room_with_password_success(String password) throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 있는 재윤과 앵맹이의 방임", password, routines, MORNING, 10, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -98,7 +123,6 @@ void create_room_with_password_success(String password) throws Exception { .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); @@ -114,10 +138,8 @@ void create_room_with_wrong_password_fail(String password) throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 있는 재윤과 앵맹이의 방임", password, routines, MORNING, 10, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -139,10 +161,8 @@ void create_room_with_too_many_routine_fail() throws Exception { routines.add("코드 리뷰 달기"); routines.add("책 읽기"); routines.add("산책 하기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -158,10 +178,8 @@ void create_room_with_too_many_routine_fail() throws Exception { 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 @@ -182,10 +200,8 @@ void create_morning_room_wrong_certify_time_fail(int certifyTime) throws Excepti List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, certifyTime, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -206,10 +222,8 @@ void create_night_room_wrong_certify_time_fail(int certifyTime) throws Exception List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); - CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, NIGHT, certifyTime, 4); - String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -231,22 +245,18 @@ void modify_room_success() throws Exception { .certifyTime(9) .maxUserCount(5) .build(); - Participant participant = Participant.builder() .room(room) .memberId(1L) .build(); participant.enableManager(); - - Room savedRoom = roomRepository.save(room); + roomRepository.save(room); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); - String json = objectMapper.writeValueAsString(modifyRoomRequest); // expected - mockMvc.perform(put("/rooms/" + savedRoom.getId()) + mockMvc.perform(put("/rooms/" + room.getId()) .contentType(APPLICATION_JSON) .content(json)) .andExpect(status().isOk()) @@ -264,24 +274,438 @@ void unauthorized_modify_room_fail() throws Exception { .certifyTime(9) .maxUserCount(5) .build(); - Participant participant = Participant.builder() .room(room) .memberId(1L) .build(); - - Room savedRoom = roomRepository.save(room); + roomRepository.save(room); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); - String json = objectMapper.writeValueAsString(modifyRoomRequest); + String message = "{\"message\":\"방장이 아닌 사용자는 방을 수정할 수 없습니다.\"}"; // expected - mockMvc.perform(put("/rooms/" + savedRoom.getId()) + mockMvc.perform(put("/rooms/" + room.getId()) .contentType(APPLICATION_JSON) .content(json)) .andExpect(status().isNotFound()) + .andExpect(content().json(message)) + .andDo(print()); + } + + @DisplayName("비밀번호 있는 방 참여 성공") + @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("비밀번호 없는 방 참여 성공") + @Test + void enter_room_with_no_password_success() throws Exception { + // given + Room room = Room.builder() + .title("처음 제목") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + 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("방 참여 후 인원수 증가 테스트") + @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("아침 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @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("저녁 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @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.enterMorningRoom(); + } + + 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.enterNightRoom(); + } + + 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("비밀번호 불일치 방 참여시 예외 발생") + @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("test123") + .nickname("nick") + .profileImage("testtests") + .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("인원수가 모두 찬 방 참여시 예외 발생") + @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("일반 사용자의 방 나가기 성공") + @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 = Participant.builder() + .room(room) + .memberId(1L) + .build(); + + 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()); + + participantRepository.flush(); + Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + assertThat(room.getCurrentUserCount()).isEqualTo(4); + assertThat(deletedParticipant.getDeletedAt()).isNotNull(); + assertThat(deletedParticipant.getDeletedRoomTitle()).isEqualTo("5명이 있는 방~"); + } + + @DisplayName("방장의 방 나가기 - 방 삭제 성공") + @Test + void manager_delete_room_success() throws Exception { + // given + Room room = Room.builder() + .title("1명이 있는 방~") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(8) + .build(); + Participant participant = Participant.builder() + .room(room) + .memberId(1L) + .build(); + participant.enableManager(); + roomRepository.save(room); + participantRepository.save(participant); + + // expected + mockMvc.perform(delete("/rooms/" + room.getId())) + .andExpect(status().isOk()) + .andDo(print()); + Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + assertThat(roomRepository.findById(room.getId())).isEmpty(); + assertThat(deletedParticipant.getDeletedAt()).isNotNull(); + } + + @DisplayName("방장이 위임하지 않고 방 나가기 실패") + @Test + void manager_exit_room_fail() throws Exception { + // given + Room room = Room.builder() + .title("7명이 있는 방~") + .roomType(NIGHT) + .certifyTime(21) + .maxUserCount(10) + .build(); + Participant participant = Participant.builder() + .room(room) + .memberId(1L) + .build(); + 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("아침 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @Test + void exit_and_decrease_morning_room_count() throws Exception { + // given + Room room = Room.builder() + .title("방 제목") + .password("1234") + .roomType(MORNING) + .certifyTime(9) + .maxUserCount(5) + .build(); + + Participant participant = Participant.builder() + .room(room) + .memberId(1L) + .build(); + + for (int i = 0; i < 3; i++) { + member.enterMorningRoom(); + } + + 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("저녁 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @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 = Participant.builder() + .room(room) + .memberId(1L) + .build(); + + for (int i = 0; i < 3; i++) { + member.enterNightRoom(); + } + + 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); + } } diff --git a/src/test/resources/application-test.yml b/src/test/resources/application.yml similarity index 79% rename from src/test/resources/application-test.yml rename to src/test/resources/application.yml index 33df62ba..a116451d 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application.yml @@ -1,3 +1,19 @@ +logging: + level: + org.hibernate.SQL: debug + +spring: + jpa: + properties: + hibernate: + format_sql: true + + # Redis + data: + redis: + host: 127.0.0.1 + port: 6379 + oauth2: client: provider: test @@ -12,12 +28,3 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test - -# Spring -spring: - - # Redis - data: - redis: - host: 127.0.0.1 - port: 6379 From 391105f8ecdd264a42749cda02ad29396396b72c Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Fri, 3 Nov 2023 17:49:14 +0900 Subject: [PATCH 014/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=ED=95=84=EC=9A=94=ED=95=9C=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/moabam/api/dto/ModifyRoomRequest.java | 9 ++++++++- .../api/presentation/RoomControllerTest.java | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java index 4e24341a..ec44c1b5 100644 --- a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java @@ -1,13 +1,20 @@ package com.moabam.api.dto; +import java.util.List; + +import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Range; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; public record ModifyRoomRequest( @NotBlank String title, - @Pattern(regexp = "^(|[0-9]{4,8})$") String password, + @Length(max = 255, message = "방 공지의 길이가 너무 깁니다.") String announcement, + @NotNull @Size(min = 1, max = 4) List routines, + @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/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 36bce475..31ce6517 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -245,14 +245,20 @@ void modify_room_success() throws Exception { .certifyTime(9) .maxUserCount(5) .build(); + Participant participant = Participant.builder() .room(room) .memberId(1L) .build(); participant.enableManager(); + + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + roomRepository.save(room); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", null, routines, "1234", 10, 7); String json = objectMapper.writeValueAsString(modifyRoomRequest); // expected @@ -274,13 +280,19 @@ void unauthorized_modify_room_fail() throws Exception { .certifyTime(9) .maxUserCount(5) .build(); + Participant participant = Participant.builder() .room(room) .memberId(1L) .build(); + + List routines = new ArrayList<>(); + routines.add("물 마시기"); + routines.add("코테 풀기"); + roomRepository.save(room); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "1234", 10, 7); + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "방 공지", routines, "1234", 9, 7); String json = objectMapper.writeValueAsString(modifyRoomRequest); String message = "{\"message\":\"방장이 아닌 사용자는 방을 수정할 수 없습니다.\"}"; From 2c5291bbe2cf6f970431dcdb106029000f39d222 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Sat, 4 Nov 2023 02:34:26 +0900 Subject: [PATCH 015/185] =?UTF-8?q?feat:=20ec2=20dev=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EB=B0=B0=ED=8F=AC=20=EA=B5=AC=ED=98=84=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: submodule 업데이트 * feat: docker-compose 파일 세팅 * feat: nginx 템플릿 설정 * feat: Dockerfile 설정 * feat: 쉘 스크립트 파일 작성 * feat: HealthCheckController 구현 * chore: build.gradle 커버리지 항목 제외 추가 * feat: github actions ci, cd 작성 * style: ci 파일 오타 수정 --- .github/workflows/ci.yml | 12 ++ .github/workflows/develop-cd.yml | 187 ++++++++++++++++++ Dockerfile | 8 + build.gradle | 5 +- docker-compose-dev.yml | 73 +++++++ nginx/nginx.template | 60 ++++++ scripts/deploy-dev.sh | 118 +++++++++++ scripts/init-letsencrypt.sh | 86 ++++++++ scripts/init-nginx-converter.sh | 13 ++ .../presentation/HealthCheckController.java | 16 ++ src/main/resources/config | 2 +- 11 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/develop-cd.yml create mode 100644 Dockerfile create mode 100644 docker-compose-dev.yml create mode 100644 nginx/nginx.template create mode 100644 scripts/deploy-dev.sh create mode 100644 scripts/init-letsencrypt.sh create mode 100644 scripts/init-nginx-converter.sh create mode 100644 src/main/java/com/moabam/api/presentation/HealthCheckController.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 54d5168f..45bfe330 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,10 @@ jobs: java-version: '17' distribution: 'corretto' + - name: environment 세팅 + run: | + echo "${{secrets.DEV_ENV_FILE }}" > ./.env + - name: Gradle 캐싱 uses: actions/cache@v3 with: @@ -34,6 +38,14 @@ jobs: - name: Gradle Grant 권한 부여 run: chmod +x gradlew + - name: 테스트용 MySQL 도커 컨테이너 실행 + run: | + sudo docker run -d -p 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 + + - name: 테스트용 Redis 도커 컨테이너 실행 + run: | + sudo docker run --name redis-test -p 6379:6379 -d redis + - name: SonarCloud 캐싱 uses: actions/cache@v3 with: diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml new file mode 100644 index 00000000..6b8814bd --- /dev/null +++ b/.github/workflows/develop-cd.yml @@ -0,0 +1,187 @@ +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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: 디렉토리 생성 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + script: | + mkdir -p /home/ubuntu/moabam/nginx + + - name: Docker env 파일 생성 + run: + echo "${{secrets.DEV_ENV_FILE }}" > ./.env + + - name: 서버로 전송 기본 파일들 전송 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "./.env, ./docker-compose-dev.yml, init-letsencrypt.sh, ./scripts/*" + target: "/home/ubuntu/moabam" + + - name: 서버로 전송 "nginx conf 파일들" + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "./nginx/*" + target: "/home/ubuntu/moabam" + + - name: 파일 세팅 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + script: | + cd /home/ubuntu/moabam + 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 + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_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 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 + + - name: 테스트용 Redis 도커 컨테이너 실행 + run: | + sudo docker run --name redis-test -p 6379:6379 -d redis + + - 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: . + 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_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_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "docker-compose-dev.yml" + script: | + cd /home/ubuntu/moabam + echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin + sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} + ./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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..6e564b38 --- /dev/null +++ b/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", "-Dspring.profiles.active=${SPRING_ACTIVE_PROFILES}", "/moabam.jar"] diff --git a/build.gradle b/build.gradle index 8de7b745..d50f157c 100644 --- a/build.gradle +++ b/build.gradle @@ -55,7 +55,7 @@ dependencies { // Configuration Binding annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" - + // Apache Commons Lang 3 implementation 'org.apache.commons:commons-lang3:3.13.0' @@ -96,6 +96,7 @@ jacocoTestReport { "**/*ErrorMessage*", "**/*DynamicQuery*", "**/*BaseTimeEntity*", + "**/*HealthCheckController*", ] + Qdomains) }) ) @@ -127,7 +128,7 @@ sonar { 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' + ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController*' property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml new file mode 100644 index 00000000..4e92e97b --- /dev/null +++ b/docker-compose-dev.yml @@ -0,0 +1,73 @@ +version: '3.7' + +services: + nginx: + image: nginx:latest + container_name: nginx + platform: linux/arm64/v8 + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt + - /home/ubuntu/moabam/nginx/certbot/www:/var/www/certbot + - /home/ubuntu/moabam/nginx/nginx.conf:/etc/nginx/nginx.conf + command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" + certbot: + image: certbot/certbot:latest + container_name: certbot + platform: linux/arm64 + restart: unless-stopped + volumes: + - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt + - /home/ubuntu/moabam/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: always + 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} + 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: + - /home/ubuntu/moabam/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 + volumes: + - /home/ubuntu/moabam/data/mysql:/var/lib/mysql diff --git a/nginx/nginx.template b/nginx/nginx.template new file mode 100644 index 00000000..d62eef8a --- /dev/null +++ b/nginx/nginx.template @@ -0,0 +1,60 @@ +worker_processes auto; + +events { + use epoll; + worker_connections 1024; +} + +http { + + include mime.types; + sendfile on; + + map $http_upgrade $connection_upgrade { + default "upgrade"; + } + + upstream backend { + server ${BLUE_CONTAINER}:${SERVER_PORT}; + keepalive 1024; + } + + server { + listen 80; + server_name ${SERVER_DOMAIN}; + server_tokens off; + + location / { + return 301 https://$host$request_uri; + } + + location /.well-known/acme-challenge/ { + allow all; + root /var/www/certbot; + } + } + + server { + listen 443 ssl; + server_name ${SERVER_DOMAIN}; + server_tokens off; + + 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 / { + resolver ${RESOLVER_IP} valid=10s; + proxy_pass http://backend; + proxy_http_version 1.1; + proxy_set_header Host $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/scripts/deploy-dev.sh b/scripts/deploy-dev.sh new file mode 100644 index 00000000..4786c149 --- /dev/null +++ b/scripts/deploy-dev.sh @@ -0,0 +1,118 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/.env ]; then + source /home/ubuntu/moabam/.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/nginx/nginx.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" $NGINX_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" $NGINX_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/scripts/init-letsencrypt.sh b/scripts/init-letsencrypt.sh new file mode 100644 index 00000000..b9040e36 --- /dev/null +++ b/scripts/init-letsencrypt.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/.env ]; then + source /home/ubuntu/moabam/.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/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/scripts/init-nginx-converter.sh b/scripts/init-nginx-converter.sh new file mode 100644 index 00000000..ad2b88e6 --- /dev/null +++ b/scripts/init-nginx-converter.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +# .env 파일 로드 +if [ -f /home/ubuntu/moabam/.env ]; then + source /home/ubuntu/moabam/.env +fi + +export SERVER_DOMAIN=${SERVER_DOMAIN} +export SERVER_PORT=${SERVER_PORT} +export RESOLVER_IP=${RESOLVER_IP} +export BLUE_CONTAINER=${BLUE_CONTAINER} + +envsubst '$SERVER_DOMAIN $SERVER_PORT $RESOLVER_IP $BLUE_CONTAINER' < /home/ubuntu/moabam/nginx/nginx.template > /home/ubuntu/moabam/nginx/nginx.conf 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..4f67e4c2 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/HealthCheckController.java @@ -0,0 +1,16 @@ +package com.moabam.api.presentation; + +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"; + } +} diff --git a/src/main/resources/config b/src/main/resources/config index ab594df9..e5689e37 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit ab594df9fcbf13159da3bb2fbb57d792b3f9f0f9 +Subproject commit e5689e37766a6e98213c74df4336b46b79e30f4f From 7ad26294ecc05108732bde4b570e38110fd5736d Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Sat, 4 Nov 2023 03:02:46 +0900 Subject: [PATCH 016/185] =?UTF-8?q?hotfix:=20submodule=20mysql=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index e5689e37..7026a658 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit e5689e37766a6e98213c74df4336b46b79e30f4f +Subproject commit 7026a65853d700a4f25a700fd327e926b562eabf From db829cbd6546ecc58b47ca7a3cda67b73fb8d45e Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 6 Nov 2023 21:56:55 +0900 Subject: [PATCH 017/185] =?UTF-8?q?feat:=20social=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=86=A0=ED=81=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#37)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 * feat: 토큰 정보 조회 기능 및 테스트 추가 * feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경 * fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성 * fix: 코드 리뷰 반영 --- .../application/AuthenticationService.java | 35 +++-- ...uth2AuthorizationServerRequestService.java | 21 ++- .../dto/AuthorizationTokenInfoResponse.java | 11 ++ .../api/presentation/MemberController.java | 4 +- .../global/common/util/GlobalConstant.java | 1 + .../global/common/util/TokenConstant.java | 13 ++ .../com/moabam/global/config/OAuthConfig.java | 3 +- .../handler/RestTemplateResponseHandler.java | 44 ++++++ .../AuthenticationServiceTest.java | 27 +++- ...AuthorizationServerRequestServiceTest.java | 131 ++++++++++++------ .../presentation/MemberControllerTest.java | 105 +++++++++++++- ...java => AuthorizationResponseFixture.java} | 12 +- src/test/resources/application.yml | 2 + 13 files changed, 337 insertions(+), 72 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java create mode 100644 src/main/java/com/moabam/global/common/util/TokenConstant.java create mode 100644 src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java rename src/test/java/com/moabam/fixture/{AuthorizationTokenResponseFixture.java => AuthorizationResponseFixture.java} (57%) diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 11397a03..07d06d0d 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -10,10 +10,12 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.util.TokenConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -28,11 +30,33 @@ public class AuthenticationService { private final OAuthConfig oAuthConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; + public void redirectToLoginPage(HttpServletResponse httpServletResponse) { + String authorizationCodeUri = getAuthorizationCodeUri(); + oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); + } + + public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) { + validAuthorizationGrant(authorizationCodeResponse.code()); + return issueTokenToAuthorizationServer(authorizationCodeResponse.code()); + } + + public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { + String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); + ResponseEntity authorizationTokenInfoResponse + = oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + + return authorizationTokenInfoResponse.getBody(); + } + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } + private String generateTokenValue(String token) { + return TokenConstant.TOKEN_TYPE + GlobalConstant.SPACE + token; + } + private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCodeRequest) { UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder .fromUriString(oAuthConfig.provider().authorizationUri()) @@ -78,15 +102,4 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq return contents; } - - public void redirectToLoginPage(HttpServletResponse httpServletResponse) { - String authorizationCodeUri = getAuthorizationCodeUri(); - oauth2AuthorizationServerRequestService.loginRequest(httpServletResponse, authorizationCodeUri); - } - - public void requestToken(AuthorizationCodeResponse authorizationCodeResponse) { - validAuthorizationGrant(authorizationCodeResponse.code()); - issueTokenToAuthorizationServer(authorizationCodeResponse.code()); - // TODO 발급한 토큰으로 사용자의 정보 얻어와야함 : 프로필 & 닉네임 - } } diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index 339f1503..61cfa4e2 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -2,6 +2,7 @@ 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; @@ -11,9 +12,12 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.util.TokenConstant; 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; @@ -24,7 +28,9 @@ public class OAuth2AuthorizationServerRequestService { private final RestTemplate restTemplate; public OAuth2AuthorizationServerRequestService() { - restTemplate = new RestTemplate(); + restTemplate = new RestTemplateBuilder() + .errorHandler(new RestTemplateResponseHandler()) + .build(); } public void loginRequest(HttpServletResponse httpServletResponse, String authorizationCodeUri) { @@ -43,13 +49,14 @@ public ResponseEntity requestAuthorizationServer(Str MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8); HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); - ResponseEntity authorizationTokenResponse = restTemplate.exchange(tokenUri, - HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + return restTemplate.exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); + } - if (authorizationTokenResponse.getStatusCode().isError()) { - throw new BadRequestException(ErrorMessage.REQUEST_FAILED); - } + public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) { + HttpHeaders headers = new HttpHeaders(); + headers.add(TokenConstant.AUTHORIZATION, tokenValue); + HttpEntity httpEntity = new HttpEntity<>(headers); - return authorizationTokenResponse; + return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); } } diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java b/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java new file mode 100644 index 00000000..9268516d --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +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/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index b2c60743..0554173e 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -7,6 +7,7 @@ import com.moabam.api.application.AuthenticationService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -25,6 +26,7 @@ public void socialLogin(HttpServletResponse httpServletResponse) { @GetMapping("/login/kakao/oauth") public void authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse) { - authenticationService.requestToken(authorizationCodeResponse); + AuthorizationTokenResponse tokenResponse = authenticationService.requestToken(authorizationCodeResponse); + authenticationService.requestTokenInfo(tokenResponse); } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index a74b791d..3146450a 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -10,6 +10,7 @@ public class GlobalConstant { public static final String COMMA = ","; public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; + public static final String SPACE = " "; public static final String TO = "_TO_"; public static final long EXPIRE_KNOCK = 12; diff --git a/src/main/java/com/moabam/global/common/util/TokenConstant.java b/src/main/java/com/moabam/global/common/util/TokenConstant.java new file mode 100644 index 00000000..0da12b30 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/TokenConstant.java @@ -0,0 +1,13 @@ +package com.moabam.global.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class TokenConstant { + + public static final String TOKEN_TYPE = "Bearer"; + public static final String ACCESS_TOKEN = "access_token"; + public static final String REFRESH_TOKEN = "refresh_token"; + public static final String AUTHORIZATION = "Authorization"; +} diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java index c83b6a77..9c2b545a 100644 --- a/src/main/java/com/moabam/global/config/OAuthConfig.java +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -23,7 +23,8 @@ public record Client( public record Provider( String authorizationUri, String redirectUri, - String tokenUri + String tokenUri, + String tokenInfo ) { } 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..c234dbf1 --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java @@ -0,0 +1,44 @@ +package com.moabam.global.error.handler; + +import java.io.IOException; + +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) { + try { + return response.getStatusCode().isError(); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + @Override + public void handleError(ClientHttpResponse response) { + try { + HttpStatusCode statusCode = response.getStatusCode(); + validResponse(statusCode); + } catch (IOException ioException) { + throw new BadRequestException(ErrorMessage.REQUEST_FAILED); + } + } + + 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/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index 744b466f..cd884e1a 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -22,10 +22,11 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.fixture.AuthorizationTokenResponseFixture; +import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -46,14 +47,15 @@ class AuthenticationServiceTest { @BeforeEach public void initParams() { oauthConfig = new OAuthConfig( - new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url"), + new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", + "http://tokenInfo/url"), new OAuthConfig.Client("provider", "testtestetsttest", "testtesttest", "authorization_code", List.of("profile_nickname", "profile_image")) ); ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); noOAuthConfig = new OAuthConfig( - new OAuthConfig.Provider(null, null, null), + new OAuthConfig.Provider(null, null, null, null), new OAuthConfig.Client(null, null, null, null, null) ); noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService); @@ -115,7 +117,7 @@ void authorization_grant_success() { AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse("test", null, null, null); AuthorizationTokenResponse authorizationTokenResponse = - AuthorizationTokenResponseFixture.authorizationTokenResponse(); + AuthorizationResponseFixture.authorizationTokenResponse(); // When when(oAuth2AuthorizationServerRequestService.requestAuthorizationServer(anyString(), any())).thenReturn( @@ -157,4 +159,21 @@ void token_request_mapping_success() { () -> assertThat(authorizationTokenRequest.code()).isEqualTo(code) ); } + + @DisplayName("토큰 변경 성공") + @Test + void generate_token() { + // Given + AuthorizationTokenResponse tokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); + AuthorizationTokenInfoResponse tokenInfoResponse + = AuthorizationResponseFixture.authorizationTokenInfoResponse(); + + // When + when(oAuth2AuthorizationServerRequestService.tokenInfoRequest(eq(oauthConfig.provider().tokenInfo()), + eq("Bearer " + tokenResponse.accessToken()))) + .thenReturn(new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); + + // Then + assertThatNoException().isThrownBy(() -> authenticationService.requestTokenInfo(tokenResponse)); + } } diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java index 305aa885..c54b93f5 100644 --- a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -1,7 +1,7 @@ package com.moabam.api.application; import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; import java.io.IOException; @@ -14,10 +14,11 @@ import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Mockito; 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; @@ -25,8 +26,10 @@ 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.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.error.exception.BadRequestException; @@ -43,11 +46,6 @@ public class OAuth2AuthorizationServerRequestServiceTest { @Mock RestTemplate restTemplate; - String uri = "https://authorization/url?" - + "response_type=code&" - + "client_id=testtestetsttest&" - + "redirect_uri=http://redirect/url&scope=profile_nickname,profile_image"; - @BeforeEach void initField() { ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); @@ -57,16 +55,21 @@ void initField() { @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() throws IOException { - // given + void authorization_code_uri_generate_success() { + // Given MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); - // when + // When oAuth2AuthorizationServerRequestService.loginRequest(mockHttpServletResponse, uri); - // then + // Then assertThat(mockHttpServletResponse.getContentType()) .isEqualTo(MediaType.APPLICATION_FORM_URLENCODED + GlobalConstant.CHARSET_UTF_8); assertThat(mockHttpServletResponse.getRedirectedUrl()).isEqualTo(uri); @@ -74,9 +77,9 @@ void authorization_code_uri_generate_success() throws IOException { @DisplayName("redirect 실패 테스트") @Test - void redirect_fail_test() { - // given - HttpServletResponse mockHttpServletResponse = Mockito.mock(HttpServletResponse.class); + void redirect_fail() { + // Given + HttpServletResponse mockHttpServletResponse = mock(HttpServletResponse.class); try { doThrow(IOException.class).when(mockHttpServletResponse).sendRedirect(any(String.class)); @@ -92,32 +95,33 @@ void redirect_fail_test() { } } - @DisplayName("Authorization Server에 토큰 발급 요청") + @DisplayName("Authorization Server 토큰 발급 요청") @Nested class TokenRequest { @DisplayName("토큰 발급 요청 성공") @Test - void toekn_issue_request_success() { - // given + void token_issue_request_success() { + // Given String tokenUri = "test"; MultiValueMap uriParams = new LinkedMultiValueMap<>(); - ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); - // when - when(restTemplate.exchange( - eq(tokenUri), - eq(HttpMethod.POST), - any(HttpEntity.class), - eq(AuthorizationTokenResponse.class)) - ).thenReturn(authorizationTokenResponse); + HttpHeaders headers = new HttpHeaders(); + headers.add("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8"); + HttpEntity> httpEntity = new HttpEntity<>(uriParams, headers); // When - when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(200)); + doReturn(new ResponseEntity(HttpStatus.OK)) + .when(restTemplate).exchange( + eq(tokenUri), + eq(HttpMethod.POST), + any(HttpEntity.class), + eq(AuthorizationTokenResponse.class)); + oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams); // Then - assertThatNoException().isThrownBy( - () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)); + verify(restTemplate, times(1)) + .exchange(tokenUri, HttpMethod.POST, httpEntity, AuthorizationTokenResponse.class); } @DisplayName("토큰 발급 요청 실패") @@ -128,23 +132,70 @@ void token_issue_request_fail(int code) { String tokenUri = "test"; MultiValueMap uriParams = new LinkedMultiValueMap<>(); - ResponseEntity authorizationTokenResponse = mock(ResponseEntity.class); + // 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); - when(restTemplate.exchange( - eq(tokenUri), - eq(HttpMethod.POST), - any(HttpEntity.class), - eq(AuthorizationTokenResponse.class)) - ).thenReturn(authorizationTokenResponse); + // 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 - when(authorizationTokenResponse.getStatusCode()).thenReturn(HttpStatusCode.valueOf(code)); + doThrow(new HttpClientErrorException(HttpStatusCode.valueOf(code))) + .when(restTemplate).exchange( + eq(tokenInfoUri), + eq(HttpMethod.GET), + any(HttpEntity.class), + eq(AuthorizationTokenInfoResponse.class)); // Then - assertThatThrownBy( - () -> oAuth2AuthorizationServerRequestService.requestAuthorizationServer(tokenUri, uriParams)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.REQUEST_FAILED.getMessage()); + assertThatThrownBy(() -> + oAuth2AuthorizationServerRequestService.tokenInfoRequest(tokenInfoUri, tokenValue)) + .isInstanceOf(HttpClientErrorException.class); } } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 114d5ba8..faae1803 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,19 +1,26 @@ package com.moabam.api.presentation; import static com.moabam.global.common.util.OAuthParameterNames.*; +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 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; @@ -27,11 +34,14 @@ import org.springframework.web.util.UriComponentsBuilder; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.AuthenticationService; import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; -import com.moabam.fixture.AuthorizationTokenResponseFixture; +import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; +import com.moabam.global.error.handler.RestTemplateResponseHandler; @SpringBootTest @AutoConfigureMockMvc @@ -46,21 +56,26 @@ class MemberControllerTest { @Autowired OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + @SpyBean + AuthenticationService authenticationService; + @Autowired OAuthConfig oAuthConfig; - static RestTemplate restTemplate; + static RestTemplateBuilder restTemplateBuilder; MockRestServiceServer mockRestServiceServer; @BeforeAll static void allSetUp() { - restTemplate = new RestTemplate(); + 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); } @@ -81,7 +96,7 @@ void authorization_code_request_success() throws Exception { ResultActions result = mockMvc.perform(get("/members")); result.andExpect(status().is3xxRedirection()) - .andExpect(header().string("Content-type", + .andExpect(MockMvcResultMatchers.header().string("Content-type", MediaType.APPLICATION_FORM_URLENCODED_VALUE + GlobalConstant.CHARSET_UTF_8)) .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); } @@ -97,19 +112,95 @@ void authorization_token_request_success() throws Exception { contentParams.add(CODE, "test"); contentParams.add(CLIENT_SECRET, oAuthConfig.client().clientSecret()); - String response = objectMapper.writeValueAsString( - AuthorizationTokenResponseFixture.authorizationTokenResponse()); - AuthorizationCodeResponse authorizationCodeResponse = new AuthorizationCodeResponse("test", null, null, null); + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + AuthorizationTokenResponse authorizationTokenResponse = + AuthorizationResponseFixture.authorizationTokenResponse(); + + String response = objectMapper.writeValueAsString(authorizationTokenResponse); + + // When + doReturn(AuthorizationResponseFixture.authorizationTokenInfoResponse()) + .when(authenticationService).requestTokenInfo(authorizationTokenResponse); + // 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)); + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isOk()) + .andDo(print()); + } + + @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(); + // 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))); + + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("토큰 정보 조회 요청") + @Test + void token_info_request_success() throws Exception { + // given + AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + + // When + doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) + .when(authenticationService).requestToken(authorizationCodeResponse); + + // expected + mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) + .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) + .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) + .andRespond(withStatus(HttpStatusCode.valueOf(200))); + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isOk()); } + + @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(authenticationService).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))); + + ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + .andExpect(status().isBadRequest()); + } } diff --git a/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java b/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java similarity index 57% rename from src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java rename to src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java index ecfa1506..1dc1f3d6 100644 --- a/src/test/java/com/moabam/fixture/AuthorizationTokenResponseFixture.java +++ b/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java @@ -1,8 +1,10 @@ package com.moabam.fixture; +import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -public class AuthorizationTokenResponseFixture { +public final class AuthorizationResponseFixture { static final String tokenType = "tokenType"; static final String accessToken = "accessToken"; @@ -12,6 +14,14 @@ public class AuthorizationTokenResponseFixture { 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/resources/application.yml b/src/test/resources/application.yml index a116451d..00bf4697 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -28,3 +28,5 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test + token-info: https://api.token.com/test + From 0201932fdaf454758e400287c8b4f327128e483d Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 6 Nov 2023 21:57:51 +0900 Subject: [PATCH 018/185] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#41)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ResponseStatus + DTO 방식으로 변경 * feat: 아이템, 인벤토리 Entity 생성 * feat: 아이템 목록 조회 API 구현 * test: containsExactly 검증으로 수정 * test: 아이템 목록 조회 Service 테스트 * test: 인벤토리 아이템 목록 조회 Repository 테스트 * feat: Stream 유틸 클래스 생성 및 적용 * fix: ItemFixture를 통한 아이템 생성 시 build() 추가 * test: 구매하지 않은 아이템 목록 조회 Repository 테스트 * feat: MethodArgumentTypeMismatchException handler 추가 * test: 아이템 목록 조회 Controller 테스트 * fix: Mapper 생성자 접근 레벨 private으로 변경 * feat: ItemType 생성 및 적용 * refactor: 잘못된 요청 타입 에러 메시지 상수화 --- .../moabam/api/application/ItemService.java | 31 ++++++ .../moabam/api/domain/entity/Inventory.java | 52 ++++++++++ .../com/moabam/api/domain/entity/Item.java | 90 +++++++++++++++++ .../api/domain/entity/enums/ItemCategory.java | 6 ++ .../api/domain/entity/enums/ItemType.java | 7 ++ .../repository/InventoryRepository.java | 9 ++ .../repository/InventorySearchRepository.java | 33 +++++++ .../api/domain/repository/ItemRepository.java | 9 ++ .../repository/ItemSearchRepository.java | 43 +++++++++ .../java/com/moabam/api/dto/BugMapper.java | 2 +- .../java/com/moabam/api/dto/ItemMapper.java | 33 +++++++ .../java/com/moabam/api/dto/ItemResponse.java | 17 ++++ .../com/moabam/api/dto/ItemsResponse.java | 13 +++ .../com/moabam/api/dto/ProductMapper.java | 7 +- .../api/presentation/ItemController.java | 28 ++++++ .../api/presentation/ProductController.java | 8 +- .../global/common/util/StreamUtils.java | 17 ++++ .../error/handler/GlobalExceptionHandler.java | 18 +++- .../global/error/model/ErrorMessage.java | 2 + .../api/application/ItemServiceTest.java | 57 +++++++++++ .../api/application/ProductServiceTest.java | 7 +- .../InventorySearchRepositoryTest.java | 67 +++++++++++++ .../repository/ItemSearchRepositoryTest.java | 96 +++++++++++++++++++ .../api/presentation/ItemControllerTest.java | 64 +++++++++++++ .../presentation/ProductControllerTest.java | 2 +- .../com/moabam/fixture/InventoryFixture.java | 14 +++ .../java/com/moabam/fixture/ItemFixture.java | 40 ++++++++ 27 files changed, 757 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ItemService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Inventory.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Item.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ItemType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/InventoryRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ItemRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/ItemMapper.java create mode 100644 src/main/java/com/moabam/api/dto/ItemResponse.java create mode 100644 src/main/java/com/moabam/api/dto/ItemsResponse.java create mode 100644 src/main/java/com/moabam/api/presentation/ItemController.java create mode 100644 src/main/java/com/moabam/global/common/util/StreamUtils.java create mode 100644 src/test/java/com/moabam/api/application/ItemServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/api/presentation/ItemControllerTest.java create mode 100644 src/test/java/com/moabam/fixture/InventoryFixture.java create mode 100644 src/test/java/com/moabam/fixture/ItemFixture.java diff --git a/src/main/java/com/moabam/api/application/ItemService.java b/src/main/java/com/moabam/api/application/ItemService.java new file mode 100644 index 00000000..9d9aca77 --- /dev/null +++ b/src/main/java/com/moabam/api/application/ItemService.java @@ -0,0 +1,31 @@ +package com.moabam.api.application; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.dto.ItemMapper; +import com.moabam.api.dto.ItemsResponse; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class ItemService { + + private final ItemSearchRepository itemSearchRepository; + private final InventorySearchRepository inventorySearchRepository; + + public ItemsResponse getItems(Long memberId, ItemType type) { + List purchasedItems = inventorySearchRepository.findItems(memberId, type); + List notPurchasedItems = itemSearchRepository.findNotPurchasedItems(memberId, type); + + return ItemMapper.toItemsResponse(purchasedItems, notPurchasedItems); + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Inventory.java b/src/main/java/com/moabam/api/domain/entity/Inventory.java new file mode 100644 index 00000000..4921ab7d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Inventory.java @@ -0,0 +1,52 @@ +package com.moabam.api.domain.entity; + +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +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; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Item.java b/src/main/java/com/moabam/api/domain/entity/Item.java new file mode 100644 index 00000000..defce5f4 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Item.java @@ -0,0 +1,90 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.entity.enums.ItemCategory; +import com.moabam.api.domain.entity.enums.ItemType; +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 = "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; + + @Column(name = "image", nullable = false) + private String 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, String 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; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java new file mode 100644 index 00000000..581698c0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java @@ -0,0 +1,6 @@ +package com.moabam.api.domain.entity.enums; + +public enum ItemCategory { + + SKIN; +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java new file mode 100644 index 00000000..f21fbc68 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java @@ -0,0 +1,7 @@ +package com.moabam.api.domain.entity.enums; + +public enum ItemType { + + MORNING, + NIGHT; +} diff --git a/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java b/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java new file mode 100644 index 00000000..bac07502 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Inventory; + +public interface InventoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java new file mode 100644 index 00000000..e15c017c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java @@ -0,0 +1,33 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QInventory.*; +import static com.moabam.api.domain.entity.QItem.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.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 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(); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/ItemRepository.java b/src/main/java/com/moabam/api/domain/repository/ItemRepository.java new file mode 100644 index 00000000..ae5eede0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ItemRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Item; + +public interface ItemRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java new file mode 100644 index 00000000..8f799973 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QInventory.*; +import static com.moabam.api.domain.entity.QItem.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.core.types.dsl.BooleanExpression; +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)) + .where( + DynamicQuery.generateEq(type, item.type::eq), + DynamicQuery.generateEq(memberId, this::filterByMemberId)) + .orderBy( + item.unlockLevel.asc(), + item.bugPrice.asc(), + item.goldenBugPrice.asc(), + item.name.asc()) + .fetch(); + } + + private BooleanExpression filterByMemberId(Long memberId) { + return inventory.memberId.isNull() + .or(inventory.memberId.ne(memberId)); + } +} diff --git a/src/main/java/com/moabam/api/dto/BugMapper.java b/src/main/java/com/moabam/api/dto/BugMapper.java index 57bea850..d596c6dd 100644 --- a/src/main/java/com/moabam/api/dto/BugMapper.java +++ b/src/main/java/com/moabam/api/dto/BugMapper.java @@ -5,7 +5,7 @@ import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class BugMapper { public static BugResponse toBugResponse(Bug bug) { diff --git a/src/main/java/com/moabam/api/dto/ItemMapper.java b/src/main/java/com/moabam/api/dto/ItemMapper.java new file mode 100644 index 00000000..b468a5e4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemMapper.java @@ -0,0 +1,33 @@ +package com.moabam.api.dto; + +import java.util.List; + +import com.moabam.api.domain.entity.Item; +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.getImage()) + .level(item.getUnlockLevel()) + .bugPrice(item.getBugPrice()) + .goldenBugPrice(item.getGoldenBugPrice()) + .build(); + } + + public static ItemsResponse toItemsResponse(List purchasedItems, List notPurchasedItems) { + return ItemsResponse.builder() + .purchasedItems(StreamUtils.map(purchasedItems, ItemMapper::toItemResponse)) + .notPurchasedItems(StreamUtils.map(notPurchasedItems, ItemMapper::toItemResponse)) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/dto/ItemResponse.java b/src/main/java/com/moabam/api/dto/ItemResponse.java new file mode 100644 index 00000000..13c83ace --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemResponse.java @@ -0,0 +1,17 @@ +package com.moabam.api.dto; + +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/ItemsResponse.java b/src/main/java/com/moabam/api/dto/ItemsResponse.java new file mode 100644 index 00000000..a0d323c8 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/ItemsResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record ItemsResponse( + List purchasedItems, + List notPurchasedItems +) { + +} diff --git a/src/main/java/com/moabam/api/dto/ProductMapper.java b/src/main/java/com/moabam/api/dto/ProductMapper.java index 0851d490..bd2fce60 100644 --- a/src/main/java/com/moabam/api/dto/ProductMapper.java +++ b/src/main/java/com/moabam/api/dto/ProductMapper.java @@ -3,11 +3,12 @@ import java.util.List; import com.moabam.api.domain.entity.Product; +import com.moabam.global.common.util.StreamUtils; import lombok.AccessLevel; import lombok.NoArgsConstructor; -@NoArgsConstructor(access = AccessLevel.PROTECTED) +@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class ProductMapper { public static ProductResponse toProductResponse(Product product) { @@ -22,9 +23,7 @@ public static ProductResponse toProductResponse(Product product) { public static ProductsResponse toProductsResponse(List products) { return ProductsResponse.builder() - .products(products.stream() - .map(ProductMapper::toProductResponse) - .toList()) + .products(StreamUtils.map(products, ProductMapper::toProductResponse)) .build(); } } 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..47bd5880 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -0,0 +1,28 @@ +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.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.ItemService; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.dto.ItemsResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/items") +@RequiredArgsConstructor +public class ItemController { + + private final ItemService itemService; + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public ItemsResponse getItems(@RequestParam ItemType type) { + return itemService.getItems(1L, type); + } +} diff --git a/src/main/java/com/moabam/api/presentation/ProductController.java b/src/main/java/com/moabam/api/presentation/ProductController.java index c2231d5b..7a686810 100644 --- a/src/main/java/com/moabam/api/presentation/ProductController.java +++ b/src/main/java/com/moabam/api/presentation/ProductController.java @@ -1,8 +1,9 @@ package com.moabam.api.presentation; -import org.springframework.http.ResponseEntity; +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.ProductService; @@ -18,7 +19,8 @@ public class ProductController { private final ProductService productService; @GetMapping - public ResponseEntity getProducts() { - return ResponseEntity.ok(productService.getProducts()); + @ResponseStatus(HttpStatus.OK) + public ProductsResponse getProducts() { + return productService.getProducts(); } } 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/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 2dc1f65d..b641f69b 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -1,8 +1,11 @@ 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; @@ -10,6 +13,7 @@ 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 com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; @@ -18,7 +22,6 @@ import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.exception.UnauthorizedException; -import com.moabam.global.error.model.ErrorMessage; import com.moabam.global.error.model.ErrorResponse; @RestControllerAdvice @@ -76,6 +79,17 @@ protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotV validation.put(fieldError.getField(), fieldError.getDefaultMessage()); } - return new ErrorResponse(ErrorMessage.INVALID_REQUEST_FIELD.getMessage(), validation); + return new ErrorResponse(INVALID_REQUEST_FIELD.getMessage(), validation); + } + + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public 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); } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 4421ceea..3944c5b0 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -8,6 +8,7 @@ public enum ErrorMessage { INVALID_REQUEST_FIELD("올바른 요청 정보가 아닙니다."), + INVALID_REQUEST_VALUE_TYPE_FORMAT("'%s' 값은 유효한 %s 값이 아닙니다."), ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), @@ -26,6 +27,7 @@ public enum ErrorMessage { INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), INVALID_PRICE("가격은 0 이상이어야 합니다."), INVALID_QUANTITY("수량은 1 이상이어야 합니다."), + INVALID_LEVEL("레벨은 1 이상이어야 합니다."), FCM_INIT_FAILED("파이어베이스 설정을 실패했습니다."), FCM_TOKEN_NOT_FOUND("해당 유저는 접속 중이 아닙니다."), diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/ItemServiceTest.java new file mode 100644 index 00000000..cc2b0a43 --- /dev/null +++ b/src/test/java/com/moabam/api/application/ItemServiceTest.java @@ -0,0 +1,57 @@ +package com.moabam.api.application; + +import static com.moabam.fixture.ItemFixture.*; +import static java.util.Collections.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +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.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.dto.ItemResponse; +import com.moabam.api.dto.ItemsResponse; +import com.moabam.global.common.util.StreamUtils; + +@ExtendWith(MockitoExtension.class) +class ItemServiceTest { + + @InjectMocks + ItemService itemService; + + @Mock + ItemSearchRepository itemSearchRepository; + + @Mock + InventorySearchRepository inventorySearchRepository; + + @DisplayName("아이템 목록을 조회한다.") + @Test + void get_products_success() { + // given + Long memberId = 1L; + ItemType type = ItemType.MORNING; + Item item1 = morningSantaSkin().build(); + Item item2 = morningKillerSkin().build(); + 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(); + } +} diff --git a/src/test/java/com/moabam/api/application/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java index f3445bd3..bb63597f 100644 --- a/src/test/java/com/moabam/api/application/ProductServiceTest.java +++ b/src/test/java/com/moabam/api/application/ProductServiceTest.java @@ -17,6 +17,7 @@ import com.moabam.api.domain.repository.ProductRepository; import com.moabam.api.dto.ProductResponse; import com.moabam.api.dto.ProductsResponse; +import com.moabam.global.common.util.StreamUtils; @ExtendWith(MockitoExtension.class) class ProductServiceTest { @@ -39,10 +40,8 @@ void get_products_success() { ProductsResponse response = productService.getProducts(); // then - List productNames = response.products().stream() - .map(ProductResponse::name) - .toList(); + List productNames = StreamUtils.map(response.products(), ProductResponse::name); assertThat(response.products()).hasSize(2); - assertThat(productNames).containsOnly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); + assertThat(productNames).containsExactly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); } } diff --git a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java new file mode 100644 index 00000000..83463363 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java @@ -0,0 +1,67 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.fixture.InventoryFixture.*; +import static com.moabam.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; + +@SpringBootTest +@Transactional(readOnly = true) +class InventorySearchRepositoryTest { + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @Autowired + InventorySearchRepository inventorySearchRepository; + + @DisplayName("타입으로 인벤토리에 있는 아이템 목록을 구매일 순으로 조회한다.") + @Test + void find_items_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 find_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(); + } +} diff --git a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java new file mode 100644 index 00000000..c4d41b15 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java @@ -0,0 +1,96 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.fixture.InventoryFixture.*; +import static com.moabam.fixture.ItemFixture.*; +import static org.assertj.core.api.Assertions.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; + +@SpringBootTest +@Transactional(readOnly = true) +class ItemSearchRepositoryTest { + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; + + @Autowired + ItemSearchRepository itemSearchRepository; + + @DisplayName("타입으로 구매하지 않은 아이템 목록을 조회한다.") + @Test + void find_not_purchased_items_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 find_not_purchased_items_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 find_not_purchased_items_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 find_not_purchased_items_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/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java new file mode 100644 index 00000000..eee2e9a4 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -0,0 +1,64 @@ +package com.moabam.api.presentation; + +import static java.nio.charset.StandardCharsets.*; +import static java.util.Collections.*; +import static org.mockito.BDDMockito.*; +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.assertj.core.api.Assertions; +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.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 com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.application.ItemService; +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.dto.ItemMapper; +import com.moabam.api.dto.ItemsResponse; +import com.moabam.fixture.ItemFixture; + +@SpringBootTest +@AutoConfigureMockMvc +class ItemControllerTest { + + @Autowired + MockMvc mockMvc; + + @Autowired + ObjectMapper objectMapper; + + @MockBean + ItemService itemService; + + @DisplayName("아이템 목록을 조회한다.") + @Test + void get_items_success() throws Exception { + // given + Long memberId = 1L; + ItemType type = ItemType.MORNING; + Item item1 = ItemFixture.morningSantaSkin().build(); + Item item2 = ItemFixture.morningKillerSkin().build(); + ItemsResponse expected = ItemMapper.toItemsResponse(List.of(item1, item2), emptyList()); + given(itemService.getItems(memberId, type)).willReturn(expected); + + // expected + String content = mockMvc.perform(get("/items") + .param("type", ItemType.MORNING.name())) + .andDo(print()) + .andExpect(status().isOk()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + ItemsResponse actual = objectMapper.readValue(content, ItemsResponse.class); + Assertions.assertThat(actual).isEqualTo(expected); + } +} diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index f18b3236..a14c21c8 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -46,7 +46,7 @@ void get_products_success() throws Exception { ProductsResponse expected = ProductMapper.toProductsResponse(List.of(product1, product2)); given(productService.getProducts()).willReturn(expected); - // when & then + // expected String content = mockMvc.perform(get("/products")) .andDo(print()) .andExpect(status().isOk()) diff --git a/src/test/java/com/moabam/fixture/InventoryFixture.java b/src/test/java/com/moabam/fixture/InventoryFixture.java new file mode 100644 index 00000000..357443dc --- /dev/null +++ b/src/test/java/com/moabam/fixture/InventoryFixture.java @@ -0,0 +1,14 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Inventory; +import com.moabam.api.domain.entity.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/fixture/ItemFixture.java b/src/test/java/com/moabam/fixture/ItemFixture.java new file mode 100644 index 00000000..3d95487f --- /dev/null +++ b/src/test/java/com/moabam/fixture/ItemFixture.java @@ -0,0 +1,40 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.ItemCategory; +import com.moabam.api.domain.entity.enums.ItemType; + +public class ItemFixture { + + public static final String MORNING_SANTA_SKIN_NAME = "산타 오목눈이"; + public static final String MORNING_SANTA_SKIN_IMAGE = "/item/morning_santa.png"; + public static final String MORNING_KILLER_SKIN_NAME = "킬러 오목눈이"; + public static final String MORNING_KILLER_SKIN_IMAGE = "/item/morning_killer.png"; + public static final String NIGHT_MAGE_SKIN_NAME = "메이지 부엉이"; + public static final String NIGHT_MAGE_SKIN_IMAGE = "/item/night_mage.png"; + + public static Item.ItemBuilder morningSantaSkin() { + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_SANTA_SKIN_NAME) + .image(MORNING_SANTA_SKIN_IMAGE); + } + + public static Item.ItemBuilder morningKillerSkin() { + return Item.builder() + .type(ItemType.MORNING) + .category(ItemCategory.SKIN) + .name(MORNING_KILLER_SKIN_NAME) + .image(MORNING_KILLER_SKIN_IMAGE); + } + + public static Item nightMageSkin() { + return Item.builder() + .type(ItemType.NIGHT) + .category(ItemCategory.SKIN) + .name(NIGHT_MAGE_SKIN_NAME) + .image(NIGHT_MAGE_SKIN_IMAGE) + .build(); + } +} From 9efcd8c476168ccd341027efcea5a2ed34609bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Mon, 6 Nov 2023 23:29:36 +0900 Subject: [PATCH 019/185] =?UTF-8?q?feat:=20=EC=BD=95=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20API=20=EA=B5=AC=ED=98=84=20(feat.=20RestDoc,=20Embe?= =?UTF-8?q?dded=20Redis)=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: RestDoc 기본 설정 * feat: Embedded Redis 환경 구축 * style: 에러 메시지 변경 및 추가 * feat: 콕 찌르기 API 구현 * refactor: 콕 찌르기 키 생성 메서드 분리 * chore: redis docker 주석 처리 * chore: dump.rdb 삭제 * chore: develop-cd Redis 주석처리 * style: 주석 삭제 * style: Constant 분리 * refacotr: String.format을 활용해 Knock Key 생성 --- .github/workflows/ci.yml | 4 - .github/workflows/develop-cd.yml | 356 +++++----- .gitignore | 1 + build.gradle | 44 ++ src/docs/asciidoc/index.adoc | 76 +++ src/docs/asciidoc/notification.adoc | 20 + .../application/AuthenticationService.java | 2 +- .../api/application/NotificationService.java | 21 +- ...uth2AuthorizationServerRequestService.java | 2 +- .../repository/NotificationRepository.java | 24 +- .../presentation/NotificationController.java | 24 + .../global/common/constant/FcmConstant.java | 9 + .../{util => constant}/GlobalConstant.java | 7 +- .../global/common/constant/RedisConstant.java | 14 + .../repository/StringRedisRepository.java | 8 +- .../global/config/EmbeddedRedisConfig.java | 121 ++++ .../com/moabam/global/config/FcmConfig.java | 4 +- .../error/exception/MoabamException.java | 5 + .../error/handler/GlobalExceptionHandler.java | 8 +- .../global/error/model/ErrorMessage.java | 9 +- .../resources/binary/redis/redis-server-arm64 | Bin 0 -> 2338192 bytes src/main/resources/static/docs/index.html | 630 ++++++++++++++++++ .../resources/static/docs/notification.html | 494 ++++++++++++++ .../application/NotificationServiceTest.java | 14 +- ...AuthorizationServerRequestServiceTest.java | 2 +- .../NotificationRepositoryTest.java | 8 +- .../presentation/MemberControllerTest.java | 2 +- .../NotificationControllerTest.java | 121 ++++ .../com/moabam/fixture/MemberFixture.java | 9 + .../java/com/moabam/fixture/RoomFixture.java | 16 + .../repository/StringRedisRepositoryTest.java | 62 +- src/test/resources/application.yml | 5 + 32 files changed, 1853 insertions(+), 269 deletions(-) create mode 100644 src/docs/asciidoc/index.adoc create mode 100644 src/docs/asciidoc/notification.adoc create mode 100644 src/main/java/com/moabam/api/presentation/NotificationController.java create mode 100644 src/main/java/com/moabam/global/common/constant/FcmConstant.java rename src/main/java/com/moabam/global/common/{util => constant}/GlobalConstant.java (59%) create mode 100644 src/main/java/com/moabam/global/common/constant/RedisConstant.java create mode 100644 src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java create mode 100755 src/main/resources/binary/redis/redis-server-arm64 create mode 100644 src/main/resources/static/docs/index.html create mode 100644 src/main/resources/static/docs/notification.html create mode 100644 src/test/java/com/moabam/api/presentation/NotificationControllerTest.java create mode 100644 src/test/java/com/moabam/fixture/RoomFixture.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45bfe330..d763e7bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,10 +42,6 @@ jobs: run: | sudo docker run -d -p 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 - - name: 테스트용 Redis 도커 컨테이너 실행 - run: | - sudo docker run --name redis-test -p 6379:6379 -d redis - - name: SonarCloud 캐싱 uses: actions/cache@v3 with: diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 6b8814bd..08c90a3b 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -1,187 +1,183 @@ name: develop-CD on: - push: - branches: [ "develop" ] + push: + branches: [ "develop" ] permissions: - contents: write + 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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 - - - name: 디렉토리 생성 - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.EC2_INSTANCE_HOST }} - port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - script: | - mkdir -p /home/ubuntu/moabam/nginx - - - name: Docker env 파일 생성 - run: - echo "${{secrets.DEV_ENV_FILE }}" > ./.env - - - name: 서버로 전송 기본 파일들 전송 - uses: appleboy/scp-action@master - with: - host: ${{ secrets.EC2_INSTANCE_HOST }} - port: 22 - username: ${{ secrets.EC2_INSTANCE_USERNAME }} - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, init-letsencrypt.sh, ./scripts/*" - target: "/home/ubuntu/moabam" - - - name: 서버로 전송 "nginx conf 파일들" - uses: appleboy/scp-action@master - with: - host: ${{ secrets.EC2_INSTANCE_HOST }} - port: 22 - username: ${{ secrets.EC2_INSTANCE_USERNAME }} - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./nginx/*" - target: "/home/ubuntu/moabam" - - - name: 파일 세팅 - uses: appleboy/ssh-action@master - with: - host: ${{ secrets.EC2_INSTANCE_HOST }} - port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - script: | - cd /home/ubuntu/moabam - 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 - - - name: Github Actions IP 보안그룹에서 삭제 - if: always() - run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_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 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 - - - name: 테스트용 Redis 도커 컨테이너 실행 - run: | - sudo docker run --name redis-test -p 6379:6379 -d redis - - - 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: . - 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_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_INSTANCE_HOST }} - port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "docker-compose-dev.yml" - script: | - cd /home/ubuntu/moabam - echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin - sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} - ./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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + 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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + + - name: 디렉토리 생성 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + script: | + mkdir -p /home/ubuntu/moabam/nginx + + - name: Docker env 파일 생성 + run: + echo "${{secrets.DEV_ENV_FILE }}" > ./.env + + - name: 서버로 전송 기본 파일들 전송 + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "./.env, ./docker-compose-dev.yml, init-letsencrypt.sh, ./scripts/*" + target: "/home/ubuntu/moabam" + + - name: 서버로 전송 "nginx conf 파일들" + uses: appleboy/scp-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ${{ secrets.EC2_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "./nginx/*" + target: "/home/ubuntu/moabam" + + - name: 파일 세팅 + uses: appleboy/ssh-action@master + with: + host: ${{ secrets.EC2_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + script: | + cd /home/ubuntu/moabam + 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 + + - name: Github Actions IP 보안그룹에서 삭제 + if: always() + run: | + aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_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 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test 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: . + 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_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_INSTANCE_HOST }} + port: 22 + username: ubuntu + key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + source: "docker-compose-dev.yml" + script: | + cd /home/ubuntu/moabam + echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin + sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} + ./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_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 diff --git a/.gitignore b/.gitignore index f4a54d77..43d3e28b 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ logs/ application-*.yml src/main/resources/config !application-test.yml +dump.rdb diff --git a/build.gradle b/build.gradle index d50f157c..e98fa317 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,13 @@ java { sourceCompatibility = '17' } +ext { + snippetsDir = file('build/generated-snippets') +} + configurations { + asciidoctorExtensions + compileOnly { extendsFrom annotationProcessor } @@ -62,8 +69,17 @@ dependencies { // 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' + + // Asciidoctor + asciidoctorExtensions 'org.springframework.restdocs:spring-restdocs-asciidoctor' + + // RestDocs + testImplementation 'org.springframework.restdocs:spring-restdocs-mockmvc' } tasks.named('test') { @@ -132,3 +148,31 @@ sonar { 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/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc new file mode 100644 index 00000000..90fb923e --- /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-api.moabam.com] | +|운영(prod) | link:[api.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..e7004b80 --- /dev/null +++ b/src/docs/asciidoc/notification.adoc @@ -0,0 +1,20 @@ +== 알림(Notification) + + 콕 찌르기 알림 기능을 제공합니다. + +=== 콕 찌르기 알림 + + 1) 특정 방의 사용자가 다른 사용자를 콕 찌릅니다. + 2) 서버에서 콕 찌를 대상의 FCM Token 여부를 검증합니다. + 3) Firebase 서버에 FCM Push Messaing 알림을 비동기로 요청합니다. + 4) Firebase 서버에서 FCM Token으로 식별된 기기에 알림을 보냅니다. + +[discrete] +==== 요청 + +include::{snippets}/notifications/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/notifications/http-response.adoc[] diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 07d06d0d..02d93d78 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -14,7 +14,7 @@ import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.common.util.TokenConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; diff --git a/src/main/java/com/moabam/api/application/NotificationService.java b/src/main/java/com/moabam/api/application/NotificationService.java index 797ae048..4bf18136 100644 --- a/src/main/java/com/moabam/api/application/NotificationService.java +++ b/src/main/java/com/moabam/api/application/NotificationService.java @@ -1,5 +1,7 @@ package com.moabam.api.application; +import static com.moabam.global.common.constant.FcmConstant.*; + import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,26 +27,31 @@ public class NotificationService { @Transactional public void sendKnockNotification(MemberTest member, Long targetId, Long roomId) { + String knockKey = generateKnockKey(member.memberId(), targetId, roomId); + validateConflictKnockNotification(knockKey); validateFcmToken(targetId); - validateConflictKnockNotification(member.memberId(), targetId, roomId); String fcmToken = notificationRepository.findFcmTokenByMemberId(targetId); Notification notification = NotificationMapper.toKnockNotificationEntity(member.nickname()); Message message = NotificationMapper.toMessageEntity(notification, fcmToken); + notificationRepository.saveKnockNotification(knockKey); firebaseMessaging.sendAsync(message); - notificationRepository.saveKnockNotification(member.memberId(), targetId, roomId); + } + + private void validateConflictKnockNotification(String knockKey) { + if (notificationRepository.existsByKey(knockKey)) { + throw new ConflictException(ErrorMessage.CONFLICT_KNOCK); + } } private void validateFcmToken(Long memberId) { if (!notificationRepository.existsFcmTokenByMemberId(memberId)) { - throw new NotFoundException(ErrorMessage.FCM_TOKEN_NOT_FOUND); + throw new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN); } } - private void validateConflictKnockNotification(Long memberId, Long targetId, Long roomId) { - if (notificationRepository.existsKnockByMemberId(memberId, targetId, roomId)) { - throw new ConflictException(ErrorMessage.KNOCK_CONFLICT); - } + private String generateKnockKey(Long memberId, Long targetId, Long roomId) { + return String.format(KNOCK_KEY, roomId, memberId, targetId); } } diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index 61cfa4e2..c054add0 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -14,7 +14,7 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.common.util.TokenConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.handler.RestTemplateResponseHandler; diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java index ce705d68..130a6d7b 100644 --- a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java @@ -1,6 +1,7 @@ package com.moabam.api.domain.repository; -import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.common.constant.FcmConstant.*; +import static com.moabam.global.common.constant.GlobalConstant.*; import static java.util.Objects.*; import java.time.Duration; @@ -22,13 +23,16 @@ public void saveFcmToken(Long key, String value) { stringRedisRepository.save( String.valueOf(requireNonNull(key)), requireNonNull(value), - requireNonNull(Duration.ofDays(EXPIRE_FCM_TOKEN)) + Duration.ofDays(EXPIRE_FCM_TOKEN) ); } - public void saveKnockNotification(Long memberId, Long targetId, Long roomId) { - String key = requireNonNull(roomId) + UNDER_BAR + requireNonNull(memberId) + TO + requireNonNull(targetId); - stringRedisRepository.save(key, BLANK, requireNonNull(Duration.ofHours(EXPIRE_KNOCK))); + public void saveKnockNotification(String key) { + stringRedisRepository.save( + requireNonNull(key), + BLANK, + Duration.ofHours(EXPIRE_KNOCK) + ); } // TODO : 세연님 로그아웃 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 삭제하시면 됩니다. @@ -40,13 +44,11 @@ public String findFcmTokenByMemberId(Long memberId) { return stringRedisRepository.get(String.valueOf(requireNonNull(memberId))); } - public boolean existsFcmTokenByMemberId(Long memberId) { - return stringRedisRepository.hasKey(String.valueOf(requireNonNull(memberId))); + public boolean existsByKey(String key) { + return stringRedisRepository.hasKey(requireNonNull(key)); } - public boolean existsKnockByMemberId(Long memberId, Long targetId, Long roomId) { - String key = requireNonNull(roomId) + UNDER_BAR + requireNonNull(memberId) + TO + requireNonNull(targetId); - - return stringRedisRepository.hasKey(key); + public boolean existsFcmTokenByMemberId(Long memberId) { + return stringRedisRepository.hasKey(String.valueOf(requireNonNull(memberId))); } } 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..7121966a --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -0,0 +1,24 @@ +package com.moabam.api.presentation; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.moabam.api.application.NotificationService; +import com.moabam.global.common.annotation.MemberTest; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notifications") +public class NotificationController { + + private final NotificationService notificationService; + + @GetMapping("/rooms/{roomId}/members/{memberId}") + public void sendKnockNotification(@PathVariable Long roomId, @PathVariable Long memberId) { + notificationService.sendKnockNotification(new MemberTest(1L, "nickname"), memberId, roomId); + } +} diff --git a/src/main/java/com/moabam/global/common/constant/FcmConstant.java b/src/main/java/com/moabam/global/common/constant/FcmConstant.java new file mode 100644 index 00000000..7a0cf6d2 --- /dev/null +++ b/src/main/java/com/moabam/global/common/constant/FcmConstant.java @@ -0,0 +1,9 @@ +package com.moabam.global.common.constant; + +public class FcmConstant { + + public static final long EXPIRE_KNOCK = 12; + public static final long EXPIRE_FCM_TOKEN = 60; + public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + public static final String FIREBASE_PATH = "config/moabam-firebase.json"; +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java similarity index 59% rename from src/main/java/com/moabam/global/common/util/GlobalConstant.java rename to src/main/java/com/moabam/global/common/constant/GlobalConstant.java index 3146450a..8a78c94f 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java @@ -1,4 +1,4 @@ -package com.moabam.global.common.util; +package com.moabam.global.common.constant; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -11,9 +11,4 @@ public class GlobalConstant { public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; - - public static final String TO = "_TO_"; - public static final long EXPIRE_KNOCK = 12; - public static final long EXPIRE_FCM_TOKEN = 60; - public static final String FIREBASE_PATH = "config/moabam-firebase.json"; } diff --git a/src/main/java/com/moabam/global/common/constant/RedisConstant.java b/src/main/java/com/moabam/global/common/constant/RedisConstant.java new file mode 100644 index 00000000..fe06b7dd --- /dev/null +++ b/src/main/java/com/moabam/global/common/constant/RedisConstant.java @@ -0,0 +1,14 @@ +package com.moabam.global.common.constant; + +public class RedisConstant { + + public static final String REDIS_SERVER_MAX_MEMORY = "maxmemory 128M"; + public static final String REDIS_BINARY_PATH = "binary/redis/redis-server-arm64"; + public static final String FIND_LISTEN_PROCESS_COMMAND = "netstat -nat | grep LISTEN | grep %d"; + public static final String SHELL_PATH = "/bin/sh"; + public static final String SHELL_COMMAND_OPTION = "-c"; + public static final String OS_ARCHITECTURE = "os.arch"; + public static final String OS_NAME = "os.name"; + public static final String ARM_ARCHITECTURE = "aarch64"; + public static final String MAC_OS_NAME = "Mac OS X"; +} diff --git a/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java b/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java index 3677942c..e53ab515 100644 --- a/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java +++ b/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java @@ -4,25 +4,21 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; @Repository @RequiredArgsConstructor -@Transactional(readOnly = true) public class StringRedisRepository { private final StringRedisTemplate stringRedisTemplate; - @Transactional public void save(String key, String value, Duration timeout) { stringRedisTemplate .opsForValue() .set(key, value, timeout); } - @Transactional public void delete(String key) { stringRedisTemplate.delete(key); } @@ -33,7 +29,7 @@ public String get(String key) { .get(key); } - public Boolean hasKey(String email) { - return stringRedisTemplate.hasKey(email); + public Boolean hasKey(String key) { + return stringRedisTemplate.hasKey(key); } } 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..16587cac --- /dev/null +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -0,0 +1,121 @@ +package com.moabam.global.config; + +import static com.moabam.global.common.constant.RedisConstant.*; + +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.Objects; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.io.ClassPathResource; +import org.springframework.util.StringUtils; + +import com.moabam.global.error.exception.MoabamException; +import com.moabam.global.error.model.ErrorMessage; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import redis.embedded.RedisServer; + +@Slf4j +@Configuration +@Profile("test") +public class EmbeddedRedisConfig { + + @Value("${spring.data.redis.port}") + private int redisPort; + + private RedisServer redisServer; + + @PostConstruct + public void startRedis() { + int port = isRedisRunning() ? findAvailablePort() : redisPort; + + if (isArmMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), port); + } else { + redisServer = RedisServer.builder() + .port(port) + .setting(REDIS_SERVER_MAX_MEMORY) + .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 findAvailablePort() { + for (int port = 10000; port <= 65535; port++) { + Process process = executeGrepProcessCommand(port); + + if (!isRunning(process)) { + return port; + } + } + + throw new MoabamException(ErrorMessage.NOT_FOUND_AVAILABLE_PORT); + } + + private boolean isRedisRunning() { + return isRunning(executeGrepProcessCommand(redisPort)); + } + + private Process executeGrepProcessCommand(int redisPort) { + String command = String.format(FIND_LISTEN_PROCESS_COMMAND, redisPort); + String[] shell = {SHELL_PATH, SHELL_COMMAND_OPTION, command}; + + try { + return Runtime.getRuntime().exec(shell); + } catch (IOException e) { + throw new MoabamException(e.getMessage()); + } + } + + 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 boolean isArmMac() { + return Objects.equals(System.getProperty(OS_ARCHITECTURE), ARM_ARCHITECTURE) + && Objects.equals(System.getProperty(OS_NAME), MAC_OS_NAME); + } + + private File getRedisFileForArcMac() { + try { + return new ClassPathResource(REDIS_BINARY_PATH).getFile(); + } catch (Exception e) { + throw new MoabamException(e.getMessage()); + } + } +} diff --git a/src/main/java/com/moabam/global/config/FcmConfig.java b/src/main/java/com/moabam/global/config/FcmConfig.java index 7c95478d..0c5a7cbc 100644 --- a/src/main/java/com/moabam/global/config/FcmConfig.java +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -1,6 +1,6 @@ package com.moabam.global.config; -import static com.moabam.global.common.util.GlobalConstant.*; +import static com.moabam.global.common.constant.FcmConstant.*; import java.io.IOException; import java.io.InputStream; @@ -40,7 +40,7 @@ public FirebaseMessaging firebaseMessaging() { return FirebaseMessaging.getInstance(); } catch (IOException e) { log.error("======= firebase moabam error =======\n" + e); - throw new FcmException(ErrorMessage.FCM_INIT_FAILED); + throw new FcmException(ErrorMessage.FAILED_FCM_INIT); } } } diff --git a/src/main/java/com/moabam/global/error/exception/MoabamException.java b/src/main/java/com/moabam/global/error/exception/MoabamException.java index e4a8b5ec..c7988bf3 100644 --- a/src/main/java/com/moabam/global/error/exception/MoabamException.java +++ b/src/main/java/com/moabam/global/error/exception/MoabamException.java @@ -10,4 +10,9 @@ 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/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index b641f69b..7218b312 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -57,12 +57,18 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio return new ErrorResponse(moabamException.getMessage(), null); } - @ResponseStatus(HttpStatus.BAD_REQUEST) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(FcmException.class) protected ErrorResponse handleFcmException(MoabamException moabamException) { return new ErrorResponse(moabamException.getMessage(), null); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler(MoabamException.class) + protected ErrorResponse handleMoabamException(MoabamException moabamException) { + return new ErrorResponse(moabamException.getMessage(), null); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(NullPointerException.class) protected ErrorResponse handleNullPointerException(NullPointerException exception) { diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 3944c5b0..faa4a89f 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -7,8 +7,11 @@ @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 실행 중 오류가 발생했습니다."), ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), @@ -29,9 +32,9 @@ public enum ErrorMessage { INVALID_QUANTITY("수량은 1 이상이어야 합니다."), INVALID_LEVEL("레벨은 1 이상이어야 합니다."), - FCM_INIT_FAILED("파이어베이스 설정을 실패했습니다."), - FCM_TOKEN_NOT_FOUND("해당 유저는 접속 중이 아닙니다."), - KNOCK_CONFLICT("이미 콕 알림을 보낸 대상입니다."); + FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), + NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), + CONFLICT_KNOCK("이미 콕 알림을 보낸 대상입니다."); private final String message; } 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 0000000000000000000000000000000000000000..c3090073bcb4d7020d1363eef431e80993bc7107 GIT binary patch literal 2338192 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 z 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 Y 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 !G4 zaocP)pRJ3qS4V48J>a4fSo$uy`d{R|9J%?>aHU&oa{hw61mIpLxR)cj z9EjapOW!J}>vQVbN<5L|gpvQ?jHfy*t>>GZku_Jwho1Z29hdCw1C8rM>o*)1xB`DP z;J8ZZ(-?9h2_DG0zKJ#L13wx$J2v@2;;hIkWe?_UYJ_fkz6uO44LLW)My!4}YkLg& zq$K8Q4EdJiEVD7>Tk7H}GEWEBD&Y&sl_M||oY#0R@%j&Nj-9~LCu?#m`A2kn=e9I- z6Z&1x!snQL+m6}FmLNJzp>461tQ&M8D}RJfI`;EKL-S*E_ZN@m405R_cy2X(k>4lt zCc5A~c9*QF(DF+uf0*-Ht?~x{`X)7?h&dz-RZPqKdY z5nugH+Qk>Hx81n^2kpxG^FdDod1qyP8?nvx8v~ISbigfPBewm355e8^dbEl_G1G+9n4AnK=i>0o;(Ikn@rsK4baTX z!@xtHiJyO`)ZfbsrFeeG{b^zn(EB%&gXq9_**hG;b7nGwdxGdVXC-RNk4k){-tALI zvZsLiElafCJyPhmtYO)EcS6q>d_%kq#h(YHjsHu${a5?(2RDW_ECRQSLe6O`Mwe^Q z-~P|6b^AH}k~y7_(=`-3?M&d_|I98o@k9O3_pr|FQseENmt;@i zkKu|}c(%ag6`HszT*I$J_FUHH)Rt-NPtq6m;tu#I=vCLp4`K%ha$2Ig7)Z zi)+(@IxcK8z6kaya5wvRfn7rHBJfe`I6hl^<+~+*wBt@~Y6p9*lYO-MPHk5QIlA00 z1)3F&AOmOeJXoS2=XP%yRK!r@GX3=`JW}09A2$mc(sR_6YL^Q zjaQp|`d(zwyO1BHy!Fh@m;}E{b%J)@_t}=}_Zaoce3JgQ}V|ez0!^m7B&&gi; zkEet96tlqFN_(QCEhgr=iumhqJ%lcV|AtN32*18wTSuni1AVP zh^5pY_(*FgKONJ!%=NzOrmOgF z;sjWqah?a5(@-1=pf8dbraRPo1(*B@2DW7K%!&+M9B)iNaY>ecF>rGrGZ%9fw(v1O z{vWYfX&%8pHRh$7*Z1LIx5UvM9*Z15iyZT*y1(aP+KuH|9?vG*)Opm_xU4bNGA8jG zSVK-Ckw3(raV&7$vj%yDu?ijxp-&a)b9|gBU1b>4eOC}03O+SnmWfT)F}*4z=Ta3i zuIEf(cwXt*9Gk+k2~Q^DkT$+e|I?6X}t^N&7d<2)tY-vO*E6}**y>cO-j z>}l{uRdXf&5ZSt4neTBgJnl3u1 z4ZtB(KeG`yh<@e-`WYX5g~XTq09aUbtEX_b`>@a#=wC~p_G3L)axeNCAAE^b&ieNG ze&0^w-lCJQ)27mkK_~midpL>7u=w5tbY_Zf44~iruNYfzeJ_R77g}C)Zc3)f`j#tW zVx~KnJxcOLkt4V^gIw~mKZRrISz#TH*>v@^B;@xu@~PY9sDjHiB1}yTcNY6 zKSxxKB|m(q>O+HP3TD#9q3O{d*bo`2MG`L-9=2 ze+9mcd~f->u7tL)#D+5!92J;1uli)NF;bzB|bUyRqmz#cIampKAsck%QAeNbI{R{FUge)-j&jK@gWquVf9ym zU0WEtg*!41eEFLu>*%1D%ycDfm6%N}&Nl=*(nXn0!SOWC3iv2-OUhRIWN7@Zm3Zq| zl@C|qiG=T}05@7Ah$nh86Mv0(&ztXKcNSa#cN*b;Sx?=I@q6)&)}|68sCbU5(Zn;t z|4JRia5VcKBAy$(5PmxyoiupocfL z`dle6LXL~8H1!CJ!5jJhFgn`OO>U*(&aCGWH0IZWsNY{SE%s2iAj$Y_*5XQ+tZJ zpA2$4C-p1;mvGr1>W%a$SE@5i#c~NDIDzZlUavAqs=xXS$z%G+1) zd@Fh}ZRiKoV{>axyCI(gF^Y_GGi!DiuG@Q21dVTEr+ zLbId#(ePQ&@c)msvyYFexElYxyTR-xAa4mtcrjTJ%?41>KnQ|t!iypJ8nM1qC7`wm zs4XZeAd*0}2?3iMqgJpQY;Civ*l0mXYafC>eHzrZ0bknM_A!9938)1`$s#nr@0mL{ zxmf~jpI`sj&)qw7?wK<)XU?2CbLNbD4dvz&|KB+y1%8<~vlv@qyB)*W68l{fdbUwL z)lxo4O&42l@P2FSRBQFdvZAs#&^y_SlY6epWlt^?Z!H*5R`kR3I75jI*O)R!{3@#h z_*FjpWLePZM4&A#-CGr}j(n;o5p`Lm^D2D6MB7DcZ*FIS6c}W{jH9jP+tLUqozu)cj>vq1C^;-bh zD?0ImVQ+0c_?zKt2md+1NLnUo?Ub{ha)QC6jid{B* z|M6ina~f+5!xFj%3%)Vi7iAUM=f@28l>b_6)DDhA&ci<{J`QnR ziGg+S{ncZwCg@h*ev)SG(BBSoefz%t*2g!D6&n_xiE<4)zy&|z3pDN-L#;g;_A?i3*bU44qUgA7 zs3ks~@MO_G7wpq+*t0b3I`C@PKe%8Yb;BM8mT_v7WvY1J0Zqr-F8J@d;fE3%Qe;ip zB+G%_;evg@4f~>|<89iG{YRns6&LJcH|#ewZ0oSD{(Ig9`wcfNbGmAa_QD&z+Hi~zra4`g1yQOdzXg&{%|V~pF@FN>w=x>hFzjzZyR9+&|w93l?!&V z8}?oe`}PPc6}+a#eYXq#JU9Gu4PSkh70|FHF4$3S*atQ2_32iC_?tq<%`VtfH|%N+ z+mwNSy$xIBg6-#qeOSX@Fw)vX>^H%CsSDQQhFz;+502y>MH_aC3-;(d7thyd*wES5 zo-7-7ybJadH|!%Cwk~Ly#M%@Z&T+x+al<~YVZW1Q<*}bAu)|!i@4I22*02M{Sa}-n z02l0AZrF&1JwC?Tp<(;DV4K{q8#HXkSj*|-V^?*L{jF};XEp3=-T2>f!9VPRZ<;J~LpRh>r( zuS+IB?}Gh?3--eY6T(eb^u${StoWfx-M$X2%o9q+-l*|D(2e&o7wlDT*jtQf$3@1n zGTN%-^|;#m(KAx>FRXp@chw@8iB; zvFq0-^xQjbyVbC=!G8pQe^YFue-2p7ukEINtcxD8#~R7;tOM9b)^z1lzC(-r|5s?a z=ii{^Iu|YeSX$EJqXjNn(p|L3|Nk^C*)Cc>zNNcQRlAIcCw^^ryBy!q-7fO~KTXR( z7cFnZ(h`V|R=8;Sql*^#|DUGiv$@^;QWs0h=y?1KyW8az7cKJtKTXS9E?QRhq@~3O z#z&i7v^?sfMgIS%XkqPgP{vWciX&D!f|7CZ(eA`8f{QpnW@{o&`oLE}M$47n4 zCC-?>+(nE0|4-Ahz(q?^EG-j|FD_cLT(rpl|1>SrT(lgzxyP7pF>>Rh8O+@WNcVAf zgAW;ADd(oRX9Ii7iSQ=Q5@s07lDNY`#U6fSsKq|_wfxrAhuw!BglSe@aj+p zK4Kl|Wk#;77md6#q9Jg<>%uKKckbxMsc`M=1=oCV%?HvR6ev@HhTV(?XHe6!DpHi2(*48Ga?=%7 zuhqCNKO6qtkAMtP>LQjNC4K)f%5aKH3Vthhy-SE{LJ+k2bC* z`k?TIbw!c9O-m9E8QZ$>eP8427q9wZNen)xA9~Oiyr7G|u6~W}i-GZ~zPH5SbL!jG z54U&YI%Qq2K10=YP7E%mu03emY~%Z<_2nMjR&C3i~NJY4A~ z&u`>b)>p`x%Vp`;>$bZA9%ijEDhAiiCL31|`c8Dyx3jcT@GT3!Sy@yW3@-s^WBP3x z=gns*+y9v{I3Fv+cURM=crR_di@vo*?f%G;U>f(b3a*G0 z*|^H!l}f*js|TLj&xmGF&-NHR;kxcNQ#9q-c%%>f(q;o`Gw_8MM(jEkYr0Aq6X1I< z2467H%Xqz^yPgT)WQ^sCO%M8_>EG7)?zHLK5rgj}`pUcM3$y-J^o1*n+IPm#x7hC2 z%`y0T?bj!}aqf)3AN1?&iprvJdrZGBMMhEQ@5kW$qBW*px7zqxr41vM_*{Z#ys>R| zqN`s@biJ3^IM>ABJgI-*i^1u8{?s^E+HHAP3{H1jR-z{dPfS+)Cw(2%eH{+3Evl@H zX-m#D8r1v77<{$xa}sl{q6Ju~xLXV7phaQE?e_w|| zg4l9`(f(a+6;4>Ic!VgiMYH$E@W>BreEnnaN&TGhs_^}#8($Fq6`u1isz8?L_Hty& zCL7;ZU++2o9X=LWTq^XnpWQ{Df2~bl=|bqsilOhhGooem<=4)jCD!RQzqv(W)XhB*DF=vivxlKy+Ma&1w4RV; zd?oW2a$NDRTQ7MJo~6!hF*r--+jW+4s_HvpY!|*l_%y4ceo4Y&V_R39@7HzyqfO(x zG58$W)Pu%fo=jt9u;OGI|Dwl#49<4OzoXmj6xr18Tt#E6)cJ)+iYnK`zl_yN^yHmm zU$A-48LMWzn!h43IGy>zMQ1~BcCx}NeB+0YcYdmIIpb@O-Oo?N;Bxmf^i>Djle*fl zQtK%)<|`d}?DX?r_52@$&#AAAw(4MMTvwe%##gRo>{I8`a79tr*F)Yl80~i7+!=${ z=^N>5#xFi2XLi%Mwo>s+Fmbx zqUp2e+y>(d<}=n8XT{*0kkPH@(RK%e57|6e48Gaeo2g@N`c-Oda3Z*Gw6Zige{|v-_TPAI5JAlt1ZFDT=VMKhkD4T!R)i9Bcpsq!ywlFa#pIr z=!0zyyR?7&tP@Flzf&ak-F=iH_TA<)2Ui)JP4UG!fj>U+4w4Q?+*P?BbA|Yf?Hgu2 zihbbj9|x>0v}x_@7s|QNmaQLLXdO?t@6UXN_xHcN&`QHrdlb9wr>}L}JX*DVx3kN( z`_u-`w&X^!rxHsRf0-eXL~P0OozV45=t|Lc=i)VH^u~C@TEYJ5yfwz54B`xlU3oY7 z#n(x`k^hc;<#c6bZh)S`T$@?X9h9@sX&buZ_MJb?u)%TF6;+_~h=r zWALeFUsLk4yYs8aFJ`Z??R@-$X@9qWM;`uJr-~cVK57__2mrQ)TI<-JV&b6od)2)rTd&we({s8hkP4=?BU z4D6gkPX#j&m=QM2z*E7b0W;c$@tq1L0L(ZWrt|t!&>RG2f(`TKsbDxCNPKNY^QWhR z$pvPn4fD~dVDf;u+=ltnsbFRUbFB@t`&2Mj19P(tv-4CiHvn_H4byZgnEAlmX~VpF zDwtAWmfA2co(g6$Fe`1C=S~H)448*(n5Rw!Qx42}8|Kkd!BhhCm<_Y`R4|tKdgS5j z@w)P1>hyYu+2z5n$%}te9R44D$isJMMLhA?OiT96e3R69aK;aD(2RPs&P;Xn1@%cblY)^qM(-b;`=2Cz>k`2hFt`63ius$C^uC4w~!mi%p0WGM)skzzF5E8{g(!&HXi*0beA+T>SDh zVCC+j67pBEr!RYz1P`w*zAF@c@+Qs@>^2%7C<;Z7bDsSPue!f?OWL`}ROXefU+XJp z7pje|oLz`7n|cKQzvY~RmRN&wPwAhL9ry6I# zi}*E9D4$MZCO!@=B{iI9^>P=C%qwBup+kIAJD|a6HLL{sU*;%@=heCwKOlTsbAKLJ zD`x===6;XP?|je#m*)!67R+xeXUK*9RYmgMlLQd z`VHwPR%|C`oneU_-%mNA^^Z)Mhg=Pn8=DKtukR=^>L>clZ@%oCWJLWH+*^qcy7;H( zL2F_t6upCZvCrTKEVSQ2%n$j$gE^;|IK~V7O;bzpi<}Lwp2>XBO=umEDyf5d( ziXCcP3a&)%`4e0T)bEb@2KR+E3|e14Z`*co<>A}Ao-*YB9?DoxEbHys$2P*bruD?O z*~~fndBkAYL%Hh*&YN^6W745n@+CgQqLceN zWHy#aJKsRe@O8!sr;ZM8=Uw$hTVhvVEV!$@Xra?b8Qp!fn)KU9?_6W?9@T;($&VmFB3!24~d5{k~<;LS&NP`-j2>Q!w!6hh7Zuj zXE(}O4(^=3#5h#R_eb-MUEHU9pn^EC18JWU%5u&}N^GB0;0@r%ifllCQ+(ArLHSU= z(ZZdE4{(phkTV-ER9^ zrwZEYDEpZtPt(?J@CZ($I>Tiya}E1&6f_HTXGcM z)qIn|x0lgQookF;j$Y+6@af$(#cCsF!yd+6tL^u;dlP*j?^2$~mLKr0WcGwvCtBl; z6D4uTzk$=#olUAQh+kO`Ps%sN8b`H*L%*-n@4_2`vqHa@=`y~f-<$M%8SgS@La(Zu zTOXA9k#ZY+@6BmU_u2QeAeSdJBTsyz2U|ON1|J$^t-6Fe_n4>Dd;36Z6)~sb(}r5& zyh(cHv007k&b@18EiY$e)@~@K{JFH3%o!ELg5&&PqkHb&Au_9Nuxi&r;EHKe@#*_( z0x{@#e!i{ucxOkvW<=%JxdNU4p*_#xHy&p-GtVh`#Ju06=elO*x>uO% zmg0vdy2IKVZQWt3n&*-t{R?bAFz0Nb@XaN^>U^S!dqiZOs_!w++2;pko|E&5&Nq7* z??agzGnnTDUgo({)5sARnddekKV+V(S97J{u+MWyUpi=dH=2E31nu2#7-_3 zUeNQ}h*4@@Yti#sgEOzCknZMPDc4}F(W!l7e6BmoRQu>V9z>4(qM5qV#zN;FEnh z|APJ@>o%!hIdmG-&owXgXk)?o-+bG%E$uOdET}gAI5S#8|C;DMzXq?2FTpFif#9v9 z9iQQz^kV66_&byLQraj0d@JXLe(s`WQx`4WI(u(*tb?9o-~0u-kI8uQpbHUCc?B`z zyu^o_ZtQAfJV}4E4z70A!QnN-I)cO*$s*3kJkEYX$J|BC(~FkoSnD`{dMEe)uJiY+ zDl^mH4{&x>&W)Myy-%Lp9V|Mm`I3FU)Ld+zFEtO{(yeb^o`i`xTQShh7zaNRDupZ&wB9a!Zyk?WpMNc@K@BdD;)na>gQwCu^1@#;f(% zO3pxi_P{&s(03JWGC@}bJQY_!f2WoPF!?t9#-Iso0 zm^nIlbLyaQzzCMo_u)Q9^iymS&!CgF(ud)GdE1wLZm`80nKM@z=#0O}~?BI^|6<%hJJw8c5V zG3IwW_+{QXGPq*HHrn$^`a;feyZa)@eL%^oFOnmJd-O$e2ads!mEd5_WYw?vx}3-9;!`<~W6OY~$ab%Z&M*W1o(`Yq!|!?M`oB`LQ0lbH zwiBH+UjGpA)c(&aJ#9J8JQzD>Kall{9-E*Y~T`((_9kbN>{eekk-%>IUN>KyqY z{lqt|+;OUK|D{_Vydm=c-;@Wh@Qovz{#AMK6!qtQ$-5TUP8uOLZv%$BXc9AoBoLMb< zaOI<=_=?HgDD5_fHmUD?rl_s+r%Po0u%Ef`fHx^i*4eC62Cna8G!_O7>*z?s;tbg# z+I#LNjE&1oBf6g0&D%}iBDq6pJL`@no=viz`1WW^#j}6+9BcjsWks)1XGKTe1(t7g zC4b+NP8fFHMbNr1xi4`d5*CT9N9S(H=)+yxJLla*i6K-S^XO zVmvryRAL`VGSPef!|_7=J<25Z17()bw$m=cP9<^0yu=mr5?9P?Y_4?T1^tjSne1b= zT}&*^i}O|N|1k9oPR&1_J*uXL{*d(J>`Xdo%a1Ip>qt zAOnRa`0{EU$Dv5!4fJ8HVKip$ENm=bzEo+C5hq5fbJ2Kl}OdxG4p0R6i-zudSJei=MEWn%|C?E7A5o6s?a{!XKf zg_n$F%Q`CSjM`lGYZ$YQ)4X;(=DW#zl=vFL``;8^M|X7YX5PCst`_|?1%9aZnd2?L ze`aF>bGXcbGl(-D0RM>llU;e`d&h&Fvbd?X>fTcENitO|RV1 zpy+4Ls9;>E|BRtF=#ZG)v2*nfVoRP_aXI)d1MeL07gEk_+OB{#wB2@JG1q*-T=OM! z4f|mB8k%`#I&)41W9aKA`cG8+$z0QZymPDi&s-B`u2KKjV{2xv@gMKp!aL`Q$8!dG zZtg;C%wjLWE}1>-Qe$GFkK8rPv^k3SX z#sa@?yS%X$ycYH64Y0-#7eMZjTZrx!+FBWtLZ|$PcO#|HY6z_ZPfx41 zEuXX=S9B1I8C=nToL$MDXuZd}R?3k0Gd|V<>8t}F< zT>gJep4c8ZpToI=9L`XsL@eqlHiPA#B{UYWf8Msw$o(AH6^xx{xkF>C(UF7x$o(A> zBV<@a<`M3Md#hex|7PSCC)nqHc97rk1ak}Fx`+X@SnilBeLb^E{JFCR8u$D983R+1 zU2Vjx@$2}FnY7BUp(TP_QUCu zeAKsXce+~BwY{9KVg|J>N#7>@)pq!-j?RUdlVqLnkF(wowh>W7mcL7Bwn@HUqru=G!JW0S(~c=tVgBq)PL5bVh8fbnlz>3RY`OD z_q(bO)4KYwl0KY3|4F;K*TagIU!6?LWZiG#t0470ru*%f?zdyQ-;U95!V_sKRLR zD!iYbjCW{E{U*O-ulFZwIWU>=FT61sc@WF%3a2^gBwGJ{ULUI3>PJ1=3SQT7bXU+; zC7z+Zw^j1V<^MZv)oY!CC*%Bg>y#%v%GwaxHg@moQ&2CLEsm^7q8R7%cG?Lyb3aE=OXu`yBal^`w*JTPl1c z`y{=6m%_qQE1j2!!3xH{sGJXcn<#!0_^Vt4uv%ZgT#e%kvgrU!P3jX`XIVy{Xp zQ2qtg!A)^iW-t`az&5u3r)HQoQE{r_)0aAkbtL@;uQ1PizozZ@_QON)9sH75(Ztwa zyNq_o9o5GwIm3ts6K6&OAP&N?#lt-L7f=a%}9JyE46z!#a)mg%f@rk6DiA3bDa zrk8a+>$jj6pEY8BNgSZ#=$Q`ua2LD}c%^R=J2*B@NCL5V=MZ0b;fcbkEEQMy{SEMH zG%K0)8umJ$dF#tU-wlkj%E8e-la_W(84m*HuI#=d$S)1NKe>m}d+Mk~u z9z%SfD&hmpse1eHBH{xTuAI|V-sGTfqqL9AM`0ymLDD#It!_zpLni}aoTtIfdzacFvEWpC+mF`FI4DhW}SU2ZBjB>VtDx? z1@PYd3yImYK#G8?GFL)**6X`ECS1f`*WKAmgjb-N}9sH?*JlhO8A{u=i_PWIxf~uNe~YlD>)<1gq{c8l}8par6si zHdChXM)A0&sX_QZ(9d^;Il^~^@tr+a6pzD40-A|;-N-(Cc5%NeI^;g!AuIhGGl&zM z0lXP{vMBHFpDfAezubSYkKeOD!7hv5`QaJHq2~X2W`iG?*}%|N2TXi&-X~vprGW7O zJyF&kOPa9p`up6;9f`9J74yH8@s!1Q8pB@mqL;>V$3WtNI_AsZ4tRtq zee6v*u#mE6@2DJ_;HlePXtW+C4%PlbZ~7pyDP)<(pxGvQX5H?rt~^8Mr4O1;-ac7h z(h^w+T-Y+jV;2wDGwL@$D)!<$M8m^>ROl)G-@-*SkkMRXn&X|NNT5=)4;I zYKo*!&7+-j*Ls?EXPF6WtBj`Il2(izCfMiZngnmtZo$*ay}C-)G2Z^#5LfHVU>$R* zudA>2A-iS2_n|k~?>dvdqpzU8n20eB0#TNUm zEh>g?V%bz|zb&Gxq>eHye2@<{VRI9^Rw?}}Yv^S5eM-J{X*As1T4lYhm$=tWvSvyE zpNFyQNRKQN>rSO!hvAdD?`XJdrZk$5H-al`nP40Biu=GQdN+$dY2}QSZ_(Gm$aqy$$O& zovdq1{M>)F9#V&W$e=jJWAK1i{C^hDXUvLRlGwf;d_N?XvE&EHuRGG2-F7c=mKhgq z_r^yzp(h`qpID<>&mh}E(3P#^T2f?guXVzw8h#L2B6>M7mRgMe9%*gj8OTVtJlK|U zhV{N@?2Ds6jZ}QI?osoU$RXhy+G2};zCGu0eqGJ!!8l|fGDzf?*g|AJkow$tw^3V8 z+3tHInxUC>e?#HclXX=eZ7J($`9^R!d+5^!dDNbu@JXiDoinxWoT+u^Omt_D-(YWd zaD+C*_s-s%{_)*Ll6y~Fd^p^7^TlC@zk_;&{(0S+kO%sHr7rv*l^aQtHe`^eme`nE zO5htufA&$=O2)fmXA)bzLznpf`RMDrl`cIv@)7-{=Oq_UcDKRT8{(3ByC=}uFRY&q zkHp({y5E9}eo+21@*lhr{1X)3;J@KNQy}*qyKIUE^zLO&k-oi&^@;4=yVpR?%uA|o zclO@5^~{MQXoCvIdIA6CKJ*I4dmFk%iu`BHSMZrgg5x&mj{u9+F7a-q}nIVUnP2z?Bkp1$Zp1W1?}rY-;#E& zK#t2d2H!+4v12;9`?g}B?%Thd+_ys*w=1Ec3|h{ojt2NdCd+^3@&+F=)(79cCw^No zHf>~bZgY$eaxC8^(}vtZq-+3l&>3SdEOYGYl#ez zcK3OGb1JDJ>N^r^m@LRnCa4%e}*rdwv_*|<2Y#4745_auc;S*IB8R%FSadZ z3>PyGI{odFcCq~-s{?=NZ+()FpE~);XBb=a`TiAM&sTIkU(xk^1v2a8dmk!i>I{UMt+@!@jG z-lWJss8g|rGTe3^N5&=Ro@7^&`O7D~r{^f2o}+wvj`A@_Wx8aV$g>3cBMW;nIz+A! z*DcQyj8N`8Ezjm@d6q?+iaetanSW-omoq3#Y7STb(NW_D&OTO-exi7Vwr0N@ztNe|4E~F3neboufDnfe zx^4d;+D7Dx+(#TPE34Vrx&81$kALkV&x0?g_X!=AJJ1quPQ7N4d2fxJ$sqRh+C($- zvl7+~gU!%x_6?I*12hC#ABZlY{*NE7=-_`#@apm1|5;B6eRWz!yXRH$Tar21Nn2jy zN$RalI=B;B;)$LvmZ;c+GG!ig?K{3gJ2bE69#Q6d-+b;3F;jCy&dNRY%@45GF6TFn z46yzBu~&IMEHC;CWjQeQD~t*Ep-Zv%v$bC2<4XKISih7apG5|XPTii)8I*;gM%FWp z?PGkw&(AfYqCaHP?xO3<9(x=0%!G!d=eV0IFO-|v89y}lrrk!?IiHA4_h4w4P=dw6nTWP(8M09O1s`pt0XOhwCkmg z)A7-f`$M^FLgv8~_KJlchnTjH!=He~w$)HyL+o>G625CIK1lz-hV?r8T4#ex>L$MN#mwup*+Ivb{S)5Xa%d0W+eaDU9mxAYYLYq^ z; zTVWXf2Rasb8+V~qqE9%n>&y85ZTjW++;1cLane5-PkHRAmdIR7yT^{F`M<*z^_fJ}n;I3;zn`3kd!7w2{b&vA}2O`k3==_*vlncgOiAmoE0O zg)D=Y?uql+wkDBZ?ls5&V=i*FXp7gFDEkce#A%yU3|w)4L(cUAchyAcJ0%BQcv{bt zy>;R$dJ1~YFDCy?^6#cTBAd4ykMEV95cJ4C-;u9UCQepx*X4}kdC!R}bI#*=E8mO0 zQu3@XT$0ZjVE9|cR@n*soAEml+We)qESSdrNEL0tx_{Arub+6p_~Ur}bLQbwmJqPd z1OIk2bMYu!7nJ|K&AZsE8q0do8!FHnk{QcKL}vUS_2!I`J@w{|53;YwJzr%pvF&B9 zj7@X+^%!&`R|e@lgdXQ)%dbB5Ia%k7Y{~9fxe0yDYid7_pSTJcDDOl4o@?eFxo+Y^ z^8WFx+>6j&!Yaxr4Sy_K_Ukap%_! z!WJvv|6}{ai+RdiFEpfKZ`*PpW#WTU<_EKKhpmjirUW@G`w3ng#^~X)hL7p^ZkDC=OrQb?fy9Zgj%l%if zc8^`NyJ;;>?(VO$Z=JkmuNbCu|LVeE?>f)#p>;kyS|T=D%FTQ`FV~k~_jGvNM z@*302jU}JpcYV)+?GqQmLn1ev_2oM+sIe}3Y<0I?YjET;(o^`}zcVkl{mpYH3Juxt za&6II^sJ-)j{_FXA zvC-T)cbv4Rod1lm8?>U&sQTZ@dV_w#2GtOla;DWXP40%E@5q-neC(^vCqChBIhzlX zB2E3cPXJzc{9>c_W6~Yl(9cQV%e$iUS2mr-f7^7*9;47168%i{4Crj5PaHZ$W;%WG zx{O2JFFR!q=E-bpE&NfqG)c9M>~XXUzdV#}?U(fN*%tQl4YHP0eRRh_OU`==y;XfZ zwF~x!8uy{2*z_kyD!#)$B|Iu+xW}N}=_xiWk%3~vlC_%Hu*AkHeJbN$Y+Y`f*5`K_ zwa-!KOxE{q9;!QlZyk5FUQ3_VKMpUw-)Zw-ze}SfoOu;pwS@YkZ#DY*d9U%K!(pq> zsz82J{RulHXT;jr%WPZY4Zc{4e<(OI-|gHY{GaJb?V9`CW!nGGQ93yOVGY4u7j*xB z={)tn4L%)e4$W#CWv|_34&Q*yTy$PDcUJVr)RF!>*v`H^ag&su;p6VO0${=m<7!8n ztVNL%Q8sy*Lt7Z63J1kv>-PP>nGc+@1`j)3x}1z&&(~jjZ)cRnPIWqIcN!1UGj9qMzf>dB}Hrs4<9PwrhqwN)bc^CYL z`r7%>U7*LdYPakZYZp8&b)8MS2CtqV_pvn2NV{x#aB8Sg?pSZ9-uo|-_De_*y=D(M zRow%#qS)<|SlcMMeg{vFPi(tM5tFg8KZNf)<4yF@O7?@e>nBCZ$ofrwqdXJnkE~tX zfrQ`ee#$s-eyEYYPMD{COM>J56OcR20dZlgVX(E1 zHu0Ip#7Z+QYyY{b?pff^R&CanYJG=%_&7(k*$sJ(?X~@hRnS+_SXRrOQgN>FDC3U) zm$6Vse>&q(-ZOX)iY~h+)y}6s)tKd89F_09Ykj7Uws*#~%IDo_CMmK))`$xXDG%Eylh z-nC`Lc+TMV;heXVm#5$1H=$kj0YdtzzTGv}^5aiUUp8jw^v*YoT}Q4Y*2@YHXEO{t z{>Amk8ElJH;8n6QW?q%PFzKfgEAp`uaZdOR`-! zI{!yJLmizHV#{>5#h;egX9vWOSnBpvJC)Hkw?u5aujsV$zd+Ui+J??~>yvEg&tOBB zb(@R_p6cv#tPUo=yux3`ggK2_&$B*6CeeNe13!}c90o`3pL#N{h^+NNld0{QrnYCA z+Ma33o*QMl+vS=`=r3-2DebH3eD%q7HmI}kKjUDxdQy&hQjU63j(Sp#Jbmar(icgz zea}5TBck}{A)9~BrvGF4bslN(c}p#K7L{`sk<|HN)}$$DjR$Hb8!KvZjpa3yjAb=B z#{D%}#(gzG z`YOLRFS=YjPxkHVdAjdZ>XFZ7S=U-2Me*>KNfGv80&RlW^26hJY(A= zpR_&sPb>d8?C!N~tMN-m50WxB={8@h@gL>sw7H`DM`NsKff=vy|DF6;{8@q@oPy7i zI(X)F;oqb2FVXlvjK%-j7;7&u!n^YQ?|Z>NNbrMG@|OvIcrTXz8#Vr4$KwC9O~0w} zZ|?>FG1B&cQ}E9h{Ib^3?PGE#R{Awx0XI|0g_S+h;Fnd%!99XRG#M zUxf9fygTimrN4iIr_=t}CRzf=N2&IAc=vm~;D3{}DB~{MyQ*fQ_raP>ugLYeRi^)I zRVCInIGcQMSei@ zp~1KEekV^Cod*L=Q>~G}Nc|=McFF&9r`FZ%u~|pj9@Z6-zenWk9n-8IQ{E9|v7{*) z-p#T$k#_G_om=H@T?LDMO~!V$d@tkW!J{x+UK}H17D>JZhY*PC~ww@zn>icm<(spol^4)fDpgUCio|_&` z)p{QKg^QO`Bl69aq<#M`Y)P)ODh0?2)_MmEZ$DYLNkg|83{4r|GqO+T<1Ts)djm?( zO4H|A()4+jG<}{Wjq@yaf22iZOss^L<$XWTZk|uYr#>w*8agF!A9=zj^F;=_^zISl zmoF?+?KHiVcy9R3E{A`Y!N2!=SJvF?eW2z(?~0nG-sLq9c<--S;JvSAzW3gmV(-$L z#ol{rzU{rc=3Cw+HFtRz*WBs7v*r$OY0V;UNzFp4~3V*p*ok1o>>U1lJ9 zPV#l=Ht0Hmo2vGHSM-|vDqk?Ks`b~LHy#(N@{PZ`>fm-y*f-&-DqrrERiC};4g02E zQRTb*@~TgM8W;B6d|8!m{+z1)Kj{$&JZ0EA%G-J!w23pi8B&=^#5zI?}<&E^8|9n zwn+5(T=wsBZ@t*M7e0Ic^>Zy{C-kP$wxMY1bi+!Y-sey;I(8-T3h`OppMmd1=A!#M z#O7f0%#g@TXu!{9pxhC?7rjMf)BDJ!>$E<(&-1vFPvW2W5HytI4>SKk&WvgqwpZtW zNS^Y;K|bMsHCOrNWTBHUTdMf;AnQ%J!@Pp;#pXQ*TFTd!HJT6DxTL>s&}EMFR;n`J z6r5}AbztH=V=j9tyB{O|&wbSWHF%wR6Mto-wSni2-yLgRPkI95ssek9S-N$b)X}2- zR&dU`g0i;mHLO*fwT|3`-vTf`?(p^PxTypAaj5N#C%SZ@k>00z=tA{EhYLOvn}gtX z=7~D;<-MOCSMp!=^Q5tAp172BN$bP2drS^d@-sx5gWH4_fqRa&lBa{eLgW8}r%S$f z%|qvF{GXB^i$BwiKhM^|-eJ!`(WvK-Z)^O2ap70=Uxf|^Op3<;UN86uxbXXS+`LWf z4#mWRk@>ADg{Q3H7h;Fd{32~DHXd_Sv(huK)@7H|cOt(o*Y7`f(Sn>h7|{CQ5uN`s z&)B+3{iR-eG_J||{Un&i4rg|{$5);`SN;YYmej$~pBgm&5wZBUjaB;7bsB$iFZi!=VXP#QF4bx>iZJMIrzr)j^g}!caZ279=~z=eKAi5SGmLv z&~oj0onOi`7T1q-oK-(sy=x-XQ=8@H>ncuF_-#YDmrG9^nr_&a0KKN4SZ{Zo+7Jt`yn>1Zh^m~}6 zgNt>0L*U!zD_OYDf#(^E>(4sRp)080rSH07Z9Z^#_j^6^Y(99Cv{UgxlE&$@;{g4> zlBYwjYR5Xe9beV?OL=zFV3*TCS}Y&DdwWlL(Xq&WzdaVe?x$xo{+oNj|9v-p%?Gb( z{7!qfYW$bG@YCK01K&|b|13$D^)Km)o;sVJR7sch@OL%- zj7$YTNYZ6}?4*b6^uCfV>*a?ve1)BUhNR2-*-3xfP8U0uT2DWu;SbvB$5?~N`a1KW z&aGqF-<5IK+xp*Remb4K<-A2k(xXH0VS)$#{&MFNtBB>Ahn+}#3)cM5_MZxhpL^H2 zz@*d0DEQ*pxTH<84|#!YUs`~CI$im{qZkqEAN)oMNa`!C*|4pxaxqm$K{fKs@SZ~ z9!KVnLSb1qiycDt6|(wBii6!zo1gPBk@O{WDjy7wTmew2UG-Uazx`xU{t zuK(5D|NZQBMOY)wK_-YEw!mE5wNBrT4PC~yPp{1#+1g0DjJY51ZhLr)8w*Xt7%bq*x!)$ls%3Hoqw0kkEOMq^m6bD ze1B;V4#mfD{;qNOpe|#|Daw%gmlu{bmJjc?7rOVnrL6yw`W4#t!sqdEa^ewA!jDk) z0E8~H^j^jPZ)-XwMxo=w@oUo5nLo;TKi`f`xDZ>W@4M5Za)w>XND}%^I66$Svj1F3 zTD#~KUt|rc%Z8w<{hqy)HqPkzbv(E>^t83=tYvo`F^SWZ06wvY`*@x%7L#3Os@fl! zpxfzLo^{9;JI{`v^cS7Ck!RDf?mo_khuXN>Nc0bQqx{meM6 zcJI9?-lb2tGiK}Q^_P=lNgJizddrRmTW^tgSkk`NZtBuo8hYw2C*#>!7nf9wtySwE zUFQQQ;Nz##ZT&-~i~eyw>F5>*MSp;FtYntd9n_;iS_36HASOH?=8l2 z=k$zSj$SbYKb{o)*D}Q)iL=Sg%q22D1JtXD{a@h|lk-{0@X58jcb>`FB6PGm+OCPQ z*UUUB@X5f3*e_IT0M-%$k6)8z5(i^cU7Q^UqrVRsZfvckFKXA?V@3Q%M-n^YkIq_G zzf0Tz=p=qe_gQR-m;BhR!qYb1elEQAh4dxy!s_27mWD4gr(W-4JddAzIG^>$Xk#(! zV>yeoTh6VY1Ft0bLx&TQ333)P!Ay8-%qFk8&*u|wLc}EYu!oNwl;&8L6#Q0VeI%^NzNGr#%tulWBBGj!r(p5M$3orr%pbh$D7qbmZ# z|9VCK@Q<&!b@<<|SY*aq>jIp~%G%_@*-xjc+`S4)<1H?q8Zc zy6?sy@vgHTn%=R<%!>FpTTjf1xr@wDj%vTd-R z6UTl|y6IUYe$E9gvzLpURe0{bK*>hc53<+e!ZVWhr@>Rg+;0*~dLQjB=R96(K&COr z?@6p^+|#!sN5-_Q8z&>r+Y$?uA7LVU64~)aw(LvEypRKb4Wi8jSG#vcG{}CDub-d& zd0Wn}rrb8tMTa)~*n6T6ecAbh#1CqtoUJ1b>qGXj3hB?bhr4|f*HLD1*Zh#A<_DQq zK9>5!3vwP*)$@|^UG>~c`%HD!Q{IK&{y|J=iGv_x!MC!zjJv_d*{B{qmoJhgbDYGH zlR9?SYfF!MT}C;w4<~h~r4F(eC-HYbr>yR3be^xkCcZ%J4DfvnW+R5f=#eu-=3p1e4G`1;_Ncm{rn@wBZEKVd)d zl0DWD9nV4%zcF+Cd51S3Fd@p^}$oI?en^{BJCcdwr-HqA#(OT|zDbsgN>`1cDm*xR; z1Uf$_w%bwS<_%}8Y~vd_3!1m+0d&LE$lN-7VCnM$@9?TL_R^o4PD~y4(oN)@_+vDl zX+$%DuODPI9#?n-UsB`7?Hpw^{&kIE$(iZ@U4?xXd2ul`Fg|wIL%;0H zgrUEgGg_tCP8Obl?=fpBi5V4u?$V8%n`JE}v931Zn|wd?x&2!*>C4ODgLPYTE$JV3 zj92k*5#8)x^IehnMz-(DZC4rehQXW~;kgSg@z>&~ZpmjQqD^)3V6anieO zIo0`p9&c@0vAQUT95|his@#(yvCBm+%2c%wIceU6KF|2=(O;;`8I`fSA$$&puh+B_k7w!VHy z=l!%to^4m&Pg*QLnD`BgzOWY_*a{E4-3t$hJjo=!=0EHIziqbrf0OS26{qa~lVfp+ zFWhH8=)Lc6bow|(*Pcsy=kz#adLlg37hdWIPn`*G^+%=;;NGM-`|JmHp00Ql__3v- zOHWHQ7IR;Tx~Fit*z+kTNj>ZJuZDc`9kYe)~838dz3E`YpbFg ziQmq7fq~pnuxK-};O_4~w93aAkyt>YqxqTZB(8Xw_r~R{ZRh%sfwr$)D*cokG0A7W zoGo%y)_o^vCy}l8drBlK@BNGk;=3dF8%*N4k7p;b)MY+NVXr~ve3|1+=D59g+vjO# z(tmB~WsL=i9XZ*ueq3oRE@91|9yAtLf@9Q#$_f?PN^r$UwY+NnqX1$fSi8y(uD@SxG=CYQg6ZZKNp)0)@djRuQ zrfaS!L0;o)*iiTvd=2~bjMrWeZwxH?x*dbhY17wl>)ECqtKx#4tGwYaQjDn3y-&CC z--$6GZS4D^b7HLSvEUnJML&)A)W*_xbY3s?F;^)14xQ`^DRFURu8{MsK0QAvKQ`tz zu@}fWSdToJ+rso&0)F#_^jRTu(>%_ym4n;UBevVA`z@>3NLu_7BRHPr?_XcaDZ1AJcm0&6DkRJiXqT6iG!^gmfI7a?VuQdgtKC^e=3^ zQ*@&_=(eV=hm?~@IqD1)d7o0px$yJ^=n}nN&bZ4Ow+=df`cdZ->Wnn>oe#cRiEoOJ z-7{LRch294&Z+nb-f{1JiM*@VIxh0E$KIFpgR>v<{0n+NM9I6P$h04H&3Dy3?F#mp zGAVECQ%c6Qh&>@s>;pR9PyoJRjnE?Z+d1iCPZ&lzeAc4v2`!F2A(`~k&24u7**d0) z4089M-}Ikk+cML7w{5-1xN!RR;FDqikz|M%aznYcy&Kdd%l!Bd%hIjmArlA#oD*dB2VmFlK=Bt?Yb-e z!|!DgYiD%1q8s^L3=cZ>W=&B2RQzPi51+_)BHx!#Z|=1jNE}ebhx72aTno?Je7jun zZSkLGMosv(l{*(@?U8av@A`vV_x<s)$Q@)UfN@i&U=QZ5o7QBfV8)0kBXS_ z!n#iB2R@#$<0X}Lkao&k6?#<8)kIjwJ9|~P>b)wDK3`+cF}>>s&tp?^#@%n8x5u5W zXCy`DBfp?Q)|N5ju6JDTZtQ&whR*1H3{ED!w=tMaI_sFC0bemUzv1Cdy9CagdMuyB zb3h*ZkjM3F(mQx>@#O_AqtA5oZtrFTV|VMQvZ?;r*jeTf53Em~(#MTH`8++F(Ia>o zn~`DGkeit&*UXc7xxqazvj@*SS;#pwkBWV0Y{d4kru#15X~+8?ss{!f(FKgj{I@T* zE~GCqkI#K4Q^i`o^=aS!zwI`$iy6N8XJM~9q2NBc*veFJw{*k3-wWLFi>%G7wO|g;`+>&m%7khylJH=9S-thf%-OK-IAD3f~WUj`!rJNSMFCvMq}rd7;i%& zP3PJ5tVmSx@~=+!`VX`o>zrHR+FO7>WF1?*ZrtRP*0IC1ZX$l?Qs0d_-r+_a?{K4z zces&whf-#M@iCI$kFxA?L&R;apneq#FP>CF+f-6+koyzlzKjCmG^=v+CR=ZQaq@i` zm50+Og+>1CPM`D>dA^)}|8w$OlDf5y?NcX39;TjJzw9w?0@UrqiYwubE8vmK z;g!qanK|gOj3w3!q7RJQAa*>l6?w^v1EvqKXYfrt{Di&8>#@FI-6%S^tQ#{X+3QB^ z2+IFvsmy^3rdrw$r`qwuk+?>~%f7RFxY=}gcv%>%tL+<-?iYI#PN~mt-NDPxh{5XiGHTq?b?(3OXi4ejQ1h*mFx#y&wn{*BlmN3 zfOCNSr_a96f4LV+?&-K+#epf?-M+A7(zJGCap|a?8-nKWwP!yWI%cBlU{A2pnZv~P zBX;A0bLWJ^tTzmEh38)u-haL)%$4)jhK&|)tXz%H=n}; z)7Be{nd9dQ-2u{rq<>1fAN&V+%6;vMUS!PdF_(qg$9TeRtITj`ydm)g^*Bk6d^yhM zSMG@1A~up{Xgqn284}4OJrBFeoD}AUr2~0K(t4+)Pn>v(m37eT^+}tGc6>bw!{DFDFMXrB7^Lp3ue1 zl`dWuy#Fa)mUgn~8xjezcP#V?ua4-IUxq{;e=@FC=+8T1^XkKzSJnQ}|B+W0hHYNm zqisI_pp28p$>7L+q}#mOyH2L}NKRT8{mI)qpT0->Kf9B4Uw}yBQJlA54#GO;7mC9X?soXtP&z@V`?~O3=l9DPow|=9#S8!-#k>(|z zSMG=(8u^$uk^4cXd6N!=*zay$zN}*?YktX-xnvA{U#I+wk=aaad0`K1w%H4S^K1aTn`Fsa^K0fwPn~D1=-?L`eE#DV2=ZL*k?CV3# zP;@eJ8NMJ6gWSE~Ziiq@JN$@t`1!-3j-R7TNxc%Ok4=lBzvz;4Pp(rObt;BG|D8Iu z@=X)6(y3D^@j9J41z5LBo$A&6*t1T5q)tOQD_y|ZUtrYTSe4Pvxgr;3{b=x9McseL z{|(HWZaDuz8%|R<{PPL+IF<3kSR#huC`-msCi7A3IC|$ZPt-s+@2B~@89FMUqr}DI5?@%Q zxyqEWz6BnjvRht-udj#KQ{nYAcs&m@cHs|;n@+5DR1;@6}|Pkv2~l=jlM^1bkD z9{a$(^=%z(Dt((o-^zO7RDHW0IO$ty3yDX{+O^ufb`>2`=>KPT^P;!l#h~nAEn+8o z_7=|IdOWo=pNOk%AJus0>0-=uPEXslH^rLw@qb;wZt&`~&xtCk` zT;%Z;q}6jr>xO?ZZcwJ+b;WKR3U9N{Yw($UEr0n1)&%Z@O<`tEvjoNKRXOkm%w3YbMF##!s&({-Z< zaCechc&%BrL8bp?9Q%{T;%j*KwO=lGS7)p8)3{@aeT?82iPoE92Y>DoOKhqVi&4El z#5;DZxx9Pf$y(X_xFXHk=1F-=+N6HQ|@aeu>Oc?e=hr z;_J-mcjTr3cl332k6=zYxFJfX3s;A8b zcjol5xw+Imci%W`@|sZN59ntfYvSOjyvD3^^BSd|a`#f!-)2V-WIeQd%$tpe6O;aU z_<7&H!-=crY`o*d<&RdKm{Y}Ch&X(vbzV*=yd7Wb_2&8wAM#vhKCvv?b60iF6|dSnA11>*);41=O?fp@P~t=@C7*67Zw@QJ}N$3z_U{QKOzD`Unc)iB!}rn%OZ7)?(2m!( z&ICs8R~QocD{Et!gEB8-9)%Bs@K5kL;=!5zw~!6Cjp8;R?KL#=frb^kL>hOCVSB7z z@xyV}=kUng`F`sN&)P@)*0snTvBMta{pibn74uv6p5Ao67rVqL?sS#0{;jl)z7cuR zlFm*QPhI*=hIPx0b37kKW-#86_g}2ZHWbHS!+-FJp@J&9e0(tG@PBEcb zYuN>5{SR32j_zfs0b zCik)lk9LxN5%~e?BynnF%oH>4wxW9|{xXv-KYbB2hgb>YLg7{H+b=9%yJ5`uQ1}@$ z@jz91)rP(k^7x-{U@hrmbLGFCzMZrPX&>_bmV(Q7!O8zqzy+pv!;O~zHe5RRlE62O z_X*%rV^Z#urtK1GyPfZIUmrFP5C6sAP5M*rpw6M5@|5>{{u3{IH!-!jC)f6g8^}}o zX8^zc{1TQvSQ9Y4+#8$_88u*O`1W;q;d7o^9)AD(dEuPmP&oR(dEu9gfmP!@-l`^# zQDqosRE?^g8U6-v7kCF$4e+E?{m$rL^>@=-wWc~R{P9zH;k%48tA1<7RsF{3SGC!U zukt>c$FZ@#RqIImAux-K#HvO750nw-Z-8+q?|A3jfs`@8GqCEYIe>4@1h!w*{bt{) zdFYYSm#x%O{N@t53vc%5#ErrE_|28i>zES;pP5p1ALk;n9-67&Q~5Tbignl_S=)bk zWq#v3$Rg}JEw+E?(8wKHZ-c)Es`0YN%iVPFUo!Wn4P-Bjr<510yrS_-_$G*4RB*%? z4)y~^`S>PO1*5Cp39`o|da$4N%wp^nntiM};97*qJQ-->+SA-vjb%lEA^=Z>~{HHG`yWbR0wkRKfa z&%Vzb^xmgR_P3mcn0xY{EH0QP`#UjtO>N;wVx1YvrpbOcx_QR zW2=CDQRB`T++j})9e$q=?tCKcfqNgVU=5PQo#A4uT6&SK1Bw04*`rIs&q3zCXJUNA z&Sz|ekVm2u%>Ntx$J$(U3qS2=&^{T+h+)RCD(B81XAjM>byWNdeqtZcCgL*^_^YiG z-1WGv4>ha1#0N*(AB(FU@UTs@w26OLUbNHWKQNoMN6IcU+B)6OdMhQ8NBmLjvJFqI zInydf4z<29z?wo@-5Z`=vL4G-|GoTAi%jJIJ>1WX?B&~}$c4O@oHHXFFhwUzw$9_d ze7ag&TVv$^G_}`kW%9obUZh{M-MqL{>)Z1^SB#TB@GYGYH7oPN((XR)|L~0tg-1V` z7xw-jFD&IOSR4A~ts6qWye$&?hOJS#E3KmC8Koq2pz)!F#(og`!?Va=Yf zBm)9T02SGh$Rr4cU{M3^tpw;YNw5mIf|?1S27+xa#0`8U;M3V(jFcwZ`FtiFRi* zKNdVV-oESQ-nM(clUuDlZQ*TB+WkSJbsx|N`>Z>CKiFj5>HEQgSuIJio>d=HUM*wH z9{C>W^9{@^(qEZ#4mpy5LuBQZ=xIu@bNhKreNO3Rll(GQ9Qb-#@LuTY)Ma_*eLPdf z2By9q<2at#A;{WFLDF4AB+S#UP=CR8o{Y|kv#b~a6M*qNjTma^W+ z7=)i*(sIAYNcgl$15MP0_;aL96VPmxc3s|CXGT#!(Y?#JGdItq|03s9W#r_Y)jpj4 zkgm^F`xTk7O=?fd$)1!-J3Q8K-JOrzj9nw`Q(^ccPDd7$GxSY|HEfj~VjEAnq$OQ5 zIPQ9ry*2RO_4dvFX6OJ%#9Iym*LwKkUDd#6NUqs@Nv_%P{$K7UU7OAQzf-v*Hio;2 z4{Hg*s{rq2jqnRhZ&`ce8 z$y{FuPpnuyfjhxGW*5!L{n4JF&_WmVt^&iXgWZks#EmKTm{gITNBU1Z z8sR_Q8`?@{-|b!9f8-6#k9=!3P3$i7_#XE^+{Razck>Hvow9Ma;)^LkFFHVlTL-g^ z#Opl%JV98A7{DXU7`zkogZOzN(!)JXrTPjj;RwXE{HCPSEbZ$Y(pPwKsQP zh>dn?VES{&I+s{$?|6T1JAUC^{eR~dz2O%^qatS=hffN9OP}K3ZeR9w|3TSlcGol-bBB8ISFn(Es7QjTb%U**LIoea_&gb5$8jmNib#v&+(!eQxC^Ov-RkMmA+k zq>QOAEnky$_R7_=kLbGg`N9l+a6d=Xj+_W%*2bu(f3h*!wIiqJUvAqt_>nKB|C#nI zTlU3t$0Lo?pQBB4R2`pA&!fz#os`*M%5?WK2K36?I55?-ad7UWjl-wrZoKx9BX(Iw zrVpZADQhlmoJ;w0DXX}XvIbCA_%q*ZyNw zDW4w8d+^$|F*l2yU+>Dvye}-fc5Up>rkLG}rwo1h zx&G+~bN$1>+l}kH83FiSfu2@*4Rs%4j=YxoHPX+e&bjlhGC6CoWiD%kPd>KmKufL6K)%f7H;3Qr{Pd@1`al1Uyc@}L zif1m*Z#8q7^Jo3KOy%6?nY_xz-hKU!)B=Aa^W+z2CRaZF{fx@A+pqRNfGqnzj;@uX zSl1>$4h=j$!yg~fy>bgO_|<2xseBZdZMfEvUJCU=-!O?V8;8_JhRudA8(AGam&WUTYs`K zdG4(nlO8=eeHCL~_{hoW#~(Q{ef}dyrw@PRIKN}lhrfFCwhYJ5pG}&8{)K&}`c1wy z8Zb$k+r!vfkhgJvp=aa!w@ljj!JOQGTh3_8*>Q!Oxzdih>z-IMnsSatr1_7^y!(n~ z9%WuU@+SLWuRr#fU;QD@e`s?rfAjBq`oEaeb>ru=d^ydZASdnb;ct4kyZ^+; z-TY_1>*_!KO|1WmFOY4&fPbuY`I`^(WX^7S-s%5z5_|dI@_vfn;Anr-clc%;(8d4R zCCJXmyN!djvj+z-$EG9aGQN$^vA^C*+F$Gb<_Kh1k%ynxEO|IWGn)oPiL8r0XM_=j zYU+`CVIR*S+2gu3Z4af`*7A? zJ1Y0yhK?OFkKKKkvc*oM=>-iuvF;z|JmhZlF;xYmpGNvp(u<9A33{E@ z7ib%J)Ea{Ml|78*0&i&-{n3!eRJ-<0K@Vi>)XPV+KImd(QI5#c_{CLov=e!?ZS1Oh z8TV3-%6pxp=iURRLGtSys@o}jCkYc#EWf2#YfYE zUUW|t|C*-mxKQPqR|nhDJ`fMC#0PG%)2sN$kS{5(m^~!UEfz{!?*Z=*49YR?<$3Dl z1miC7rTXi5BZVokN<&pJ?0sa2cyxyr)t+Vu}+x9+TnHNv59W% zZ-DW{ft+(d|JQ^J1+2!ozHc2VdbCf$zan_04}bVh`|iq2-p`1>hTkWwfnUQ8OwMA7 zu37G{+(9`F0fvaxFmOX)VXI``sex@_y_P(YvCW+>*r3^!=3>a50SnzK81G}?CSvo z-s9%+-ZX+WOzD!~A=agbZ(S1nazLK>)wE^7uY2d2-(1dfVg&T)K>uj#9rIJ#^p3m3 z+6S&#zCj}8o{!YPQ?#!h+m~|To7eVKVXFzNNweDQqOHyF3*=$ZX;p>R?+JE$6|Wqd zruf-Q)Ps#wz{|QLp0k9aPn?aN9Rr~J9YD9%*+^tc(JkH#ZjSr-QX?My;w`-2^1-ET z=ObkwbgJy3*!RkX_CtRuut0ZV>HiV}%~hhiNC|X>PiOQ(ck1-461u|1YmDp2gR8)Y zqglFd%?M~DKTq2#XRq#x_n41ElOF2S-n!Iq18>oX-j|LoJNW#^4IaOo75IC9P5H+W zo17Dzd(1$GD*OEJ6E5GP*A#u>UiHoWnfN(=rjv5#Q0_C7`^0lrxqq?CU5Aa_8Spq% z?smfEJ5=r;)VCJs*v{#c=e-6V51d6W?2OdLRB$I}h2|WcOP|~;hZ`@FSK6A+JSc6% zhC-bQ^5TO^cv10?ysR@znR`EA-i4osA+OWM`S;vNti`WFJ zJfB`_{F*wXT|2p#Q-uv3Zmh7w^1tgb<{!WdUb=6q+S^VHe8Cqxfz^Nl^H>9g3*I^$_K ze7FhO0)DFS-HE=`73SAW=FfP>SR;HSG*X6MdiJHFmre=nwA;b{QrP+Blt8Y8!xx|* zp*!|+g;r7nudahH&{yFL@z7XD`kDaTM1E0up0VibZ_u36KGRjixfO~37TUCB9E+|; zT6DdE^1r3*4EDY_>!kE=?2)vi;Q@9XAv{ubHCT1^Ax`a!Q=jv*$>vn(qj`oS*bINl z-hn;NCAuj(qeCNDx57KL9k1mytn=lxj?vB5WQUoV8(}m-=S`2=@qZ!y4dMqx24~Bj zM+JHc$`X4TIh*_6`y;9s0-uHbdUs|ISS9v=a-X$~$qD9K_9MX8RJHfG8n{SX<(%Tr zJK)2?U&9}$N2mQg_eNA_pwDQUG<95Nv~$ey%X7_^G3ayPt#?76f>$A%)^MA@a`bHI zFpYJ#&7ZX4REIV!u-hQ#@wm^?7=V4~3EE`YuqFhi(k>}~d&$Ulc7J}`hE>YWzy`+u z|G|cJHTGCSC$eAq)vqF}vF8nbOCLqvE~6gtRe+oe?xzMa=$p)8DV^dkzQBRsw~vw+ zx(MYJ+6m>0xj??9O9uwr97|JV*}icwSB=LLb{}dZZfgo+YxDOrg7A9e7|q9h>^$?> zBgq}&D_lW%Z)o%1ol6nfsfqqM>7T%&mUAgG2RMgu=P38aJH@_8zXZLUoC(5C)n%O> zY8|YZPV7ZAWMC(@EIPh9zg?;ujr=|tp&OqL(2cRsi|p%)oOWu6#joH|t7N}d&L$|n zZu2a6&$P$voRJw74U$Owc=xx{lr@2s_?m3^yIB`wP5QE zY+5_?^Aq~`=_FmvEv?6`J|3Lw2{z8~1Q+3tM)@08vXYJ6{*A)aq`;x_N`;rcH z^EuSD8Qc3B+Ax~F%DnF$AvlJ;3}Xnd*p2;Oo!IYLc6y0{NrX2Kn3|PF_ye-f%l)vm z+;g1&O`a)t1Z&W*z*6QVnFBBRFM*|X9!9>0@O_*$iG;mm*AcP-9Ljg~3zN-}ahli+ zSBcGW6TahQog{q|o8zU7r;P36g!`1uabjT8W0BQk>5G)_@UN|s-tZ!_n0aBOTi&Kzgqun zh93z2G>Hsv?SE9c@vALqx`U76qejYg**@m-nWq%p{N{YRku&PFZ>#7VYQeL7-bH?^ z1=pHbZ_4_v7JRGV-6`+j9CdDCzOdJriGjtebEG}2C#&SXM)U{DE@uR`KkQ!wTLx$* zFd5kZt;@Xi0%OxO1bDG8+swY~p<8`HXO?SiGkWr3^yGf@ z63Q;vJH(il>uGVKXOcJ8>mA<_d-Q*D6FYQ*6C& zY;%AgHj;f86hu6Y-C3UnQ}w6TtmWKs4EQK>?Yt?|g6o(IzhwTKck{d!=hmtI-=Px` zd#bNxH9e-Syjl>1b_LT~%!?{)tnbZloLL>9MvjMUlvJHuYE+39K7fQ+STn6F!^vcvw$O2~RU`kdaH6)b-Qw@TUeDZoAF5 zg@^kFx3&4E@bLUhV>Ymqatg!4TQZGYyA4-|hcC-A*4yQ`YtIARHD`x=!qeY1#3;AJ z$A^b|hZ+a%_Kyq?uN`WX+c3G9@C@4Z_ex~ce{NMue5HD6CUr3(1Jya?%KAFGQD}8p!nTb$66+UyuN7BmTj}5wc$nR*lsTKtopDK zySQk|)Kg1u$Hvnc>oI#Lrd4{eZE3;m`JDHs$9ZXki}#6?tGPUj%D?0MM+9e!H*;q^GV;V|Y?nFj%sHaHF6zvt zEd|Ys_h%KCY|CmDo8;8MJMgu#JT2(Gfwr(GZ)1H0*uDi!KY!jtsmJ zf1giv(e}!iEWTx%Jtnj#&jXQ80&$ma~Xw zBaS+UFJFV)FpfD<-RsTs{_E){KAkxS<1yK*JyP)SVA-pkFMGAivprc8d6)AfV!y5W zJ~GL;oAxTYKu6?deac+5N86HQxZ$V6thhSopC+5dA|IUPj6eHJ8G6NYYpkAy#FWt%nY?lbOxa||07Aw zVGc!C=lt^u>_?|sb@!yc1{=om{-4x+kzKb&{~o;n3{?N0zB<_3?(;F|I0M;L`YiAN zNxfp9+8MkpShwx3?s1PzHX{1TUG42*AaYD+`A-UMy{_#I_qlqIcKT!;BRuji`00V? z@V-Y&oNP{J|D}O-nxZ?KN50CsMtIPPgYEfa6Y_94e-vL{;7FY63%C;ElN&9VoW~~z z5l(veq~%YFb}D}rXBThBCk6B%?OQ)BC^nK_!NJ6JX$r5ePBccaf7Sr4ELqNczf|Ab zGRuL^44cSnBH*>pM z%E}~O_%U-|+#l%=G`*)<`90?RA?@|CAE{t}HJ#sTNvaf|QDrn2{WAJAB*9;#jpq;`m zGVxO+yeG3)R8Zhw^e6nyH_lM@$iBjvK~*2{m`&XRqj+<&IYO=7;{)R7fF*IMP^C*~UBfM=l>c%G<`cTcbkd-#0TJLioHFp38r3xRo280;EoPY&f2 z0oTiU3hY=foh^&OhoCCc8nbJtBbofC9Nd=zpM-z#WW5J$WX&z`Yy!5;z?ORfc86@D zv-HH>Vi(=y&^Qmme9l@o0~>Bv)t-HC4zz4M#pWYqL+Zjekl1}nSVI?Vh{jJb{lKWB z{WW@jwRX>CeLj)%<{Vg>nZdeO^Y$~`CmhuR2X7~E0GDQpthF`oGGohhEo*yBq^aQ- zyyj$iPR;4^oGU9?8=a1T2PYtof~UY&e4m7H+C|4#Nl}iNA$tS+eZh5p~&6A7nYiY$UO?2%pIcpfv`*19B z{;9LATR&jUmCV{S1$YV%{GjB%mXraSF$R2)GUY6jlzF{frtCkQez{_e@a7ccW05N* zue8}intW{dw3ni|qlmRH_cBYCQ}Zj3S;M0cZUCham?fJX}7eMlqit7T1>8KI49DS4pf z8Q?M&`CH0fPk0MucsQqNs`9b7NYKVgoUDmnBu;ehO^;gb^^-n}a&{_Tqsf6M=m+OS zs}{b-8k{;;@qG{U8>8Bu9QYOAuh8!g?fm(i(Hu?tWGo&fE(yKcN%U?bWn5ZYk2sAh z4D4{KGF_a*dd||(y+%5Cq~ghf<1g}kVj;Xki!adh#DbRv7R2|Z42l15;zMzph-(_- zG2^dKTKlYobN1KkaPFFlE-BN9R%iYLpZ$7YV3q$^;NgEA%Z@pGY#MQ;<(!Q$^LGh9 z^4_1-k_8W&crfzlY{t?>UD;b7*_ZwFrelFA9~~>%bmEwEu&;0k=l{LKy9YBFN9T@g zV~FlqU8={dn5XBi`YK`w>w&WU6ZP~3Vi#A+8Nu82m|g3T^-B3}pQhgrNYnQRr0Mem z()9iTY0`c`n){D+?AJW{RjGSceFe@)e=DPMH?KUKyMH4#`a+MfA-L+1N7v)0OW=9} z{t$z`?KBN*kVh|F3y(!1s}kZ1Nm;Ha2x- zKUq&O8V1+rK!-W2!F}N!2hq)l3?uwyvBS3pS*gxlBzB>_1JejELSHTOEkatt_889X zGVh#-NX!;KGKL>}w{;7jwRne|kxn2FGRsKeCm%Z!51$&mE^WIbN%;{Hp2gX>LU?2x zb4^v|u&%*O@j-Dk&-B8FWW69fL-uyM+V*(Kgj@b(k^`4RlS*D>d?M|5@nfONn4P5j zSU6?hlD&|IWM4a^$FPAj2{+he<0NU zNAfn#U6H%dq0xta^*Op`^*w-squctKn_=~H=P~gSBd|KxPXiv`uoU_L zzBhnZO*4@1UhoBD;qQmldAHcL$dpwJ9hVy34KB0e{)&q|l+YQu;%8l)GugeyyNc&e zmlVRUE1lTaqHAGH9mYZG`Ki?Lx+mzpEI#OccB<+9txM6L4*mUsyDq))zQXR*_s;rZ z#trNb9oRI|c$2+|Y|cUrIXG+VP|jL$R&HEDH_xh4ek=KHzdkPWN0Zn)Igj*g%rYDr-Y?Tn{>X~P=Y&_q}* zVJ_;7q0U(9>`I+k)DvFso7}k&UayNfW2iHhI=fER#x9KG%zCK4#$iT9#*)JA*Z|e? zl=8LD^=se}8=K#V+~n$|nd$4baoF*!+Wagt81z^>2>BeC9RzmYJObR7`hu>{7VXb; zId|87Xo=@5a?UOI%*Gp?lefpbp`2wKsGRZoiFR3BMbngWGT6_i}`}J*`J@`-W(%-c8YG zHE`x)@VVx-Xmk5J{C4#SZr&AbZvHFpza{N~Xj9f2HLpgW-Tps>-Gl!2bbL_B;s=)I zRQ%Y61G2fmfwii+{WjA4lHX;#FGvUq&vEMbqv*gF|4Ls(W(entCmue>7yrkOSNRWi zY4elSkX+x6AEL9aI*@mXvH#~0=FYs{#whxqcy`^+8rFi1;A(w=W_}##9`q8v^VRM_ z*;{#@vg>b&HWiP3`!!4kd^!0eR`1IkvH4i$h>wpsIoF1KXol#@Cu3*RRcOtm&XIQ8 zwZ{1qEp8`k(ftRmGEXu$iH+yCHvjycP*}ujIviOD;>lVJ2X_Wm|Gs_BfS%dDA zx$VdBry|0LwMR11AQv(imp66 zNk6$m`p21fMp1T5<1&@rN%>yNFI%FU-o8rx`Dxsgvqh`)vt_Rme+7B_#~A6ro_;Yu`Sv{t{EKcQ?vV(yEG|0eozq9%_ayUn zrs#LqM;LeaXT6L)40<#Cw&n7K|EAXO^LBT@gP7C$M+TkWf3q)(c`x%%gni#?y$5-3 zwZnJtE_mkmoQG#aJHfMC!|?1PJ6`7BB+59@Khn4d9dJiH6P~mAqbPIdAawcZj8g}E zj_ahJ+^~9L!s_X){4~m+@o|*t?W$EJqjMO;nrYHs=^^WiqTS--M*7W<{z04+Wjo;te>`@e(A63C;e0XRQms+=h5gfp?;P(g!L0&X%pPI~6CB6x=RxK6#yRO(Fu%C}EN!F@C^hc)XDnsEe-qE=$Jn@0I!ng93AG7$t zcgw^s)wfTrldos~VBhfu%Jfl|%mtbc+7%uOjcPxMHZz^jV)+OI`AManM%wRKUz|zj z3`u8sW=D&C{~oRDVB;XLElsl4levVs|JYM(!&d#mjc-=@&P4WStR-wtDD21Q!hS-S z8#;0SIHN7yO`&vGTKgQ5cLrgqKQ~+b>3^aA+>WeGf73dNZ(_Zw<#rL@FLlnTQ_kZF z-N@5%j++211)nr{aFiWy%l_=y4Og;%N8Tg##OK)c+I7jmE3OWTj01j}xjeJ!XZ>Z4Q74J3|5f|A0mL;7cZ`xe6?UF-o_z_=AY9Ui zhbI$Wbg5&M(9^TDPsT0-9u!VXPucAb(NH+uY$AU+-3;Tqjc)GlqVTBVLj7Le#moSY zw3=bYacQ5tdqx>053&xWO%2emR@Bq@Io}?>WqqvljqF!bzE!+?R&8$(jI(p^-pOJbFZ2H;pHua&2Ji@nU$=KIjgp> ziM4amgAeXE$+x=XZv1w}2j(&s$&s?IMyE$!;Z?$W8~I*LyEEUIY^D^CYT1gvL|HfI zGS>+HiVf@y*p?i4E!&WB+`3_yfebOydvIl8Gv8UWFEcWAEjY1Pmmqjpmp(trqN@{= z+UQE^k~Q@h>XkKK3Vg0X*-NGbe!lb^U0q_ww_0oJI%j!5qsgAr8*JOcrpvV84C+~k zyml|}5M4UIHkmI%#2yuS)z?28CaArwtwHr~dmW|HmclF^^{tpRw7w zh_vv}dWW{LepUH|kH2^NKhD!+KdLVMX{A4|a%)|Hb)P^QaIaT$U2ouR`HK9T>A(y@ zOF-9A<@Q+VdXmmJ-m$feJCQhix95cnZKjX9ob<=n*P-lwAv} zJy)LhKgs&pjgHZMlblmY4!ldgjJ}SMD(t6L*e)rzk3;sGtK2?mLvr9Rgf);(g*{`1 z)f46kh1FVNe;};>(okOUiI*IBS@IHgfIVU6{&DGBIhTsQSKyVd_b!dNNSoPuIcFrm znT4#$ioorr%dwg6tHp~gruLj(?S0u3l=;S)q>U7L+snP#wb(b2#~#xP^IUFxzr+fIr&(b~E;md&jIyn; zlnKUh!h~<^;X-wy7wW9eTiT^h3B3Ow)H$_X8Oeb^hw5Z(JF7FN zUHX*3w*R2cq3y~@4m?j8_X68fik_1L7j=kVM|>G;{t)hj_@&VEXvSgZ`F1+&cj@Z< ztjN7ic=rMH6Jm4OtzFqkfeOkF@ndjuq|i~(|FGW|>)0am>B(|y&(#-;&PnDNe2*&q zQDLU?G0J>6Q_k%cVb?6SQtotZCUzB7T5Y7Uma;SYpo=75B70%@j#d8s6(9I8Qq4y{ zf4cR_P<$xgW8`B_+Oi$J?2GI{sq{lr3=i$Oh4dM$kFTX4q4Y`c2(iCq{@A1Cum)u> zGz25o-U-{eGdc8)ZHiUimB|MCatT65f_pDf9{V)#oRq+G?D1QBFxEXnZ-!umeL!lI zX693u@SePXv?|_XMmV#-jp&^%zVL5^{!T5)G+3u&JFlyAZ=!R4n(`Y}U2?}`@4)m_ zIop{W7|!}g?1v?<<^MYVaLwOZXRgKnwKu}zcRF*XH`+5U6Msdovfm>-a5j1<8F!a$ z&+TOozbpM;dE)$gB9uKh_dd(ry4c7a^#RkS0UmQA`+08ckus^f2_D)2J?^yay~V%6 zPTSsF)&)CldvBrlo!EOfDO^noxT(`UFVkpc{UiO5cF&F#I3@*76DECZvEzKiiO%q& zG~|EMwmjzxrXypx!J)SH@cs0e$2KaLHVOQ` zqmRF*4;9^0v2}(wq2rqftPUa%@vY{La^5?ZpFmkP%D1>(C*}7b%tM&eaqG9OTT-x9 zPr+_W?BM;2id*`yHzMsBC%D1ds2O?e+`V9Z0-K;4Ih!weGNCK);`>@Em`lXBRF-`w z%A1U#?6t@p7J?&#IxVYrU>#-MPa0{hwQs$Mc-DnMZwxwLU{Xw( zo>Tg$Cun;s^M;2xN6T;c^6L?BH-8_f#E$@;(i}#PA)!np7 z!XoXo!o!5Vh3|M@V^1A^^A>ob@Z&?L;tMlrS19gF>U)^HUieT9zIoZV_lr(-Dzup; zb0cS_n`x`cU*%Hyue9@*2|kiv_U&KfSt0U7C~aMgf`Q0b(uOwJJk?c|F@iE=u9SY= zM&D~#Ys>qsyl;HNiqjeIu6%2Rhu#%m&BCq;+f((v<5J`c?#ketgqyX;8}y}i&^6e2 zB9CLgP@CN&*c89+quSp&&Dx9coi|zwP9;qZ;IPyN?ip#G`z@^&d&_TY7|X7pk}1iXVQV_{XgHtP8~( zR=oQ{@ef<^sTYc0PCWhYMSN#)kG1p3nz19?2X^RpO<2F3_^uVWXMsDezr_UM9dWMZ zv1$xX_C0@np~hgcrO)`9_~VR;jQ4rtG}TJ;*@eombv*yPP`s@(dH(|O&e>6hc5@dq zJ=j%nSgmoia&$!9d^v(YZ*-CmIb_em1?WHG5|pg%U6875shH#IwA?9Y8)jfD27NWP z;**m(rI9_APd?EV-@$f#pV*E!3oIvjOyG>I&$>0sA7&4$B&$XESH*2h3Iz^DCAAZf zkK(+)jSp7TEGcx&Us70HlG!42qPKfu(7PaB)mxFYq_B)|Y46%j+N)%!*&|h3vo!e3 zOZb=Ril4_}d8Y7$$+V?mtG;FRA6uUs|5wf%(?9Pc_z7mtk~y)SG=C|BFRanU4y8_f zj%RIrtkB=JWd;h|rG=QAQ4z3zKx@oJHw3JI6< zUq05*MGrl=dOF`CYx!6c-~Bs%%Z>cj#c3us+UB}!4I2_)Tm`mW>%NchuPXpYpr2eR z`}ew9qe_`MeAkn{W}~ijs^g%Idd5=b80|%UrsxCCOhb+#t?*sxm&EO0K1t)sw3k-t*8Zze6_;VY|*bu5AL*zRz^BjBJ zc*vdT&lEe#mt2Mu{6&5-q>gcOb#*V;3ho9$zggv+J-JN}BjYT3nD?n)@Icmlp>wN| zz$2vJeA|v+XX#<8oCmYiInM_6@O-$^}t!s>ZCtez)qebV{;=+lCY-X7=hb9=AWlbF_dqF2JR8x?7jV zSr6_d3Xi)>;-JHI$V2NMifk8myToM^cZ2j&n`$IypBk^K-&W(Q3c8Bdt7dmle*p4p3K!Z>_WA|JSm&c3yT> zCuMIBE4$*~myI3SCC2hQmJ}uwXThtqppM+4yS20FJ0e@sf5LqCnRbbm>pJ7=HIcI{ zgrDdUbj_bAK37fGOZL;So?^eD>CUt1uAV_3H1r5`>$%;UzvVq2J#YoORu{V9L(tho z&fjDWU$KUD!MNq}R9>?BCChx1SsAXQTb>pRVG|zK+1wbxBT97k)8I za>~*o&9Z0tt>L$U-}e#JZKd`0A840pKR4LVM*G=+kR5M7H`vcc``Lf6Rp&=8&OuV& zL;RNTdxYN#e!t?kir*9b*3gy(G3+122EC2kM<;z{U%~3L{p@c)8?)_r`#Hyc_P3vn z+$~K0vJJ7pqR%P2k>9`geZ=nozkU4P;kS$5U#WL|*Ps_$D61~}+24LP;%h{;#eUAQ zpZ)D;9_ryV?X=b&&CmUy#1VGJ7!Wpf|K8Y&qW)sH95vFrl)1Yg6&e-_+G)X{>U10+--R2-N3uR z`HD!hC>vN_%#$dYxx(plPZ4pSngflR_GF**1-CgwNM7_O}UNxr{wO%+^f!$frc|49p%v? z#v;Vmv+Q``>#B@{W82Azp}AuQICa;GDE2J4AOTuN#$GZ!PbjE(4@eRPkjI6L2M%2cgmUDB(Ys6e4a3QXAKg!%g+DC zbYs$5-bHdWs zb!g`|C%=F4DJS=843PV~L{F{kbqamt8N*ug)F$!erSx`jvcLO@ou9Q<+*d8%KbePq zEzc>o7gn0TcPRe@$`3EM#BPh^m-5~XEAMZVCuwg22CCc)tK5SJm&N^sbPAqDR+y7= z)OT%yF^M+JRX#0~0&|AqpJR&nim=AQ#_z)COuHSFbw{Yq#$|D?BUX559AD_r-YYt^ z_qkAB+P~3m?@-zs>i_<)t@7NIee4?>J`aeGK444Tt3&n5_Ki{Hzk6Ip`U>6#K7LG18-oQ_qd&d6>s$x{Y2tTnm+|w z&--YX=mf;S5A?ZjNQLDGNcM%C5v-NjU#L}kAXb{t+AEYtY}TTeTYi8feaL1_c!%`S zy@@`u$0+)PinekrJ8se4N_c;Gm85TCJj3@5sCQ-zeJo&)Z|eQU>h2ubN6^@lNF-0Z zmZpv{B|q7i;Pg!l#nn^%!;)vz6^ro%V2)(g7pCwfXtfDdU_T zI5F_RVsupKz&=Vru0LOwxW{?0hk@SLUmMBT8=T!ihoE@t@N?^U!6(rdyh$0-za(V* z8fCYU6lhs=jz90Pi?qLl_XbPfR_A^u1Nu%33{qii z(bL^SKQ;7GD}ZlX-WM|zUc13-l^*{MJK1C5{XG8-{gPQPLmyH7BWnh5O)hx}w!(8lmFmNsizMd554{$Eo6O75(z;LghB$O`Bqj}&lc zWhrw)DR)+~&X`}UuUMnPxwDcr%KQ<0SDd{jNZq4UUwVnJZqK-#oZDCZ8HcSE`)j8t z`xJfxn(z}K`9|v%X-U*C=fh6or$~*#;}eahcXj_%_8PO;&&gv9@*WswG`EI)i6%1F zRupy6-D>qi&U8xo;0?OeI%TIc&}w@oZU1(F=11>Z=N>vy>0N(^ z4QOIm`3aOiQublM5t)xSP?zktJj`<;`#on+Sz8CnHZ=hZv|=Oj0U>$Ds8f*EB8tma-Y(bqbL6aeYx=BkgmKRWo9@StHsD? zqAvtT57EB@Yy+n<=5j|}Hem(s9|h5as57n2^c7hRo6Ly{Zx_;6)o%PNKzAeGbYepz zFvQmDID9-aRvYD=G{qDc#M7UJ?2kKvjr92o>2q5@5(0Dh=3eQ*d-!>K@&I-@(DHiZ z*bVGEivObd&qgTOwC0F5vz9pI$aZ_HUU0sceOkeF;f+aZzcwlGcDs7;wZ(TWWlEoi z&_8U1_wj9N+)4J2pIUcZ*2Y+OTuwvx`Z94R73`A&uXZSF6yGwgTiS&u1}@=S`n^2c z<6lnRY-HvYtR19nG03yAoOheW`NI-uW-8z7;Bn$39)0g#7c#!O>*ZwY7N4{W9=-*= zn1XB06w`%oFYfbxLEyVS7C9X~htth`0j=e8cH8-rtIT4?Ldt9Y9zO@beukVA<*sh* z$g38QxZL;${NowssC;b96PrE$8+mUEw=KYD0Ja5HD_xfx${xJ9cx-_yw$j7>M@iU& zzYPEEPhU=A7jOgb@YsZt*bV@vt$pMj+kq!|PnCD<3r>Rn3BY+AseIKm@2y< zuJFF#PlfWfDA*jZ%KI7dp3lO{;|!O;M%pg0q3wIL_gu!Q1r|(d=MdjtH#v7I_sJGE zv3^g(|4}NiMINeJh#p4#95NQ1jT>g18safq(5Fg#ik=cHcp&_@wZC>$b269Rkh(Ur zFL!=nqcXBQ+8BM1yMGyD8MhETGJ@C@!)qDyExq9rEiLT9;ggoT z$~05>=@{-27QYN~cDBi3@zMh9ju?Zg)Ps}FyBFPK`(#Qiz1^?P{3AMxvDgTTAEq;5 zI*VEOW1JO#VSU9V!YdfJn&*+Nll!#OSM-MGCbap=N-Paf|0w)5{T+Nx*=Nfn%v6E+uAJc&4mvNoP`$9Nwu}P2p$${ z^BlX)^`U+ocN*(uo#l--@;F0wv{6^*2*dkvJ$bBhqfB5{SDoiDd>1oUWwgf`Z7=7l ziLc#f&9R4}Q?Y>;d`$vh#edqENOTKvX$nUciau?hFDU-XS0H0a9Zt@Uriu?`SA1n! z*5qKO?NjaT+73R|z?BwcoOV9DI?-$W<~|EgGQg9LKGl-j;YnKQ3h+dHs(mDTQOqw| z!vJ-*wkx=1eGgLKXDCx+T~y8a-;V1dr{o(5PN@7Bol7s@HG|dnmv;VTP;QWPMbC$&8+CrVNu>Kd=`wz6rBm|uOEE?a<>rrzGMTS}JAqYtYHTo5 zWK{6I0pCFBx?}#z9hN<6Dc>?K7k2`WCia@N$D>T>xX$}K_!slvVQ{VmTfUTtp2mBW zT@CN#U=;qLhpMxrFKg_nJ0gu!gq_+j!H}|(J1IM3@hEHi&xK?8;upL_1jV}c0s}o;5z@aLO^;l-E z)*}68udHry#9$+n`w>P18#z-^FX!<4te>u}%$Tm#UiwR?HmIM_skfIlrasPd*2(>J zoT1*TbYq+=zV;`qeQDFh$a(L)#2Ibug|m?%hhR53)a4mBtXn5%w7Ca!cQI$Qi#elR z%o%N6BfUm?jr8^z?e&^Eqpjv4?DIDd!bTlEs+@(cVJ-m|48nYbyaHI`RDX4E3CNKzd+}{LoEfXGVZ}+&3{(4eW#}S zb`fo#q-zPR**FL5uzkWusWY$*(Oou{+!I;NnI_K5jFh|Of^Qb)DH%xSV`L!Z_x?>} zF$Ev)K^HmSd9x?z)U`M-=V~@oX`m7|kibxE8;3G4Tu+%AI*4My$p~fhU+4aFAGMa# zXir7R))F|Qvk~1#l{<2nYH!P9txslxYYDa=e`Jop*&&Of+jvps~ zb>L#-1kbJSh_7R0=SR`2v33)kST45N4$=G7GVfAf5x601WT6pbrK9>dJlM;+iE(*p zHf{29-k}!TT412o$zEXL>Tu`FUFg1w2!G`f>n?KrPO&YG4@{FWYg=!|2mT5yUUn#% zxlmxJVe2h>BC&thM5dVU11BZi=f~%njRP+NL*acw%iob-cwTeme}Dr%UBUD@VA}LN zKJ&44K5>V~#9scTdeLJ%r+Az40lJfM7rEd~`jo-A%l*PTI~jNON#bOVb>bg=Dt=BC z?>sSF;Z;Tyws*#k37Pz71LNFo&guHYo^BaN`GlJ8&gmK41Df&Dk_nl^C%05Q$GRF` z5uYV@EX3?)O)7S1=4|S>&t2{t@k&Ism$hkrsqmBdz;S^^7;NtlJj0&rZS0Z-N9wy; zcBrBAsBaLSfgV@zx`_Rh+d_2C-IqH#$Gj2TeSomy88PkJFYUeiUe1|E03TVqfD^Mt zPtugE;CYR}lQB!Yev0Dp3mv13yKmE!+;s(KEu}8e2Rrq$O-}T{nZUxS>A|Ag;Ey$t zh8F3ZE_;IMSCg)W{ir27C(K^*=7c$MyCyiOTLX>?=bpxv9-NeQhYP)MmVW0Z*%x!J zk1}L#S(uk;6vw%Y49>Op2kxP~MQd`+DVN|QgQxdd&c(0cK3B#%%h@uf1e~6Q&gE^! zA3aP}!TXcVSnRyAvSQZ?{Ko>14D?KLmxV4m7<^KT@99Sx^y-e`OE@?0BOJR&HD)V2 zj88P*N+zSN&fF2oSNp`b@c}&$f4A@v=|d80(@&VM4nTj!PGoa**G@gLGSj7*#U)eB zZ2DaQZisGUmaZRN%DUXyt^Om?LwSjZN1^9;7{Y@t0?tG0z!}1!WwTjiD`@D~LX*^` zaW*oGUp{$Fqc3Nk|vRQe9M_{r{&)7?3NIh-peyzU#`upp$=Du$DhX9hV|u9Pps#( z8~A^}NmqC~hB|z-eVmj(7awHcvb?K#86Wpfe3$+Z+lf=xC>`Mu_e}$7k2CONm$grs zzm>7e)kfXL+OPtBMhE>Z=L__9XL>^Ss-@5dp|>}Y56+!INC+78U*H+yS8suza^7pb z@bQ_}ochSh)+e{o@0N#H2S>v{>HBizKiS`iXUxWfXF|JqHtlAze&n2>vK=`^`9-%{ zaFF*O3WqVk@JVb)6i@omWHTn-!kw6S&3u8jmFC15FY;XZbe!=ecj7g3e~fDLwQ&kw zU4WOOUGjO^D@mZ;(#EZE_yJ@tY4edzKbC{9;XcwM=#Q_1kMraqT5uu!EHu75=u6E^ zt6%c|L;ICQzr;4C!Fs2pR9(6`T^80VN%oS9MS z?YoyAqn<7B}ro#;-pX6ptrtT~PQmCE#(7k13RM=N2(DH~hST?pTkb2a}UpRD~8 zk>?-RY8IsMyosl*gPh&sD_zB7TV!pz7~h#?ah{{~tYa(0Ry7MgSiE>!CjP}M_FZMh zkalYE-7TYO=R|xH7QqJ#itlU5{=Ri9exG|OUXJ`Qi}|V;TpfzOBm{#ULYK?%VGk^% zEEi>EQC0)|aUOkZm+pa!j3@1M(mp5YoZq!>m3r0wGGp=78NFJ{k@zIeVDF(Ef$MkZ zEnh`u$e`aBpVgJl#5Wh+j_8inJ*xD{eYMD!N43Rtn1gM&4E;B-?JoH)$zwm@%DpAXNryMj z+cZ&+J?zPvv`FRy;YYcGcUN~;a=(N0f(P;F<;rZDo(*r+7f}xUgfna-L-BWEQ{+QV zS%dEKarkU#PwFmsg77HIUr1u$8;M6g48_mu5dQ`7!lQoPE<7P{knp?6cN{)0@wat| z|C_{jKNo*Phxok`kImFw!0K_@DrNkrLzldO`E3#mekJE2(9tI1JHHkj@Zy{FUi{hT%iRXH9OJ%jzT4W} z?T{SU3xCUyy@O@k<-yba-XLQD&$*FEhy)3%y(IPX;y)40h>eW7E zXstTgD3@@bW-M7-UbvZXB}>29P4022a^tIa$phtu8#_s#_e^==^Mp4+*P*kMZyiI& zO*>u4OuK+#0eVci(@55C=yc8aQF%cbrwn*1yj}KE>)ikCG9IBHG8ct#X62Dq3kPn> zHtw_fSfy3vNFS@*^_NQ@tL8T3&}LuZPw3;UFuVY#x7IM{YBjx#4LtLn8DNxG_zM3f zZDs5e82hdE*f%i-WsLo|$oX<_$36Dgi~coP^siG#SEgn?%)J?`x4462BWaqT&$PtU z%6!Hy6C4-|4m3e)COB{cU99Y7a?a<1IFJVpNM6AK!M$eiVWOU*a9};-JrNwpwC_)l zv%-hD-$TySHdAlRdG{xz1l|Qc;bZXP=dITMFf^u%okVqS<6;Yjd$gu(f#Dwa1Ko`! zJ*~dQq}iini1Eb zOIB(l)_Sa81$cvPeUpQC@hv;2d=Svu1o}(GP7~7tztz ziH`m??6jQ3tMF%{+rs-}ljIsYoM*SKh-DMHy*R5`Jw$c8=&I1}4CnERmE z&!_GqTE5#D$GCu-g$48}>hp8_Xeeji6`oc*jDLLJnv5R^wXZAwFHZ_gtaV73Y?>Ol zgK&{azV0+WDewywpBRvRkn{5=1_b6~;Ni00`p-_&BnNI#Wh4aN?=(IkFr9GWV{+dj z`V;shx<=uz@3+oeZI8wC^jXGD^>0gr@!#}sC2=7h+iqMUqYU@V^1|p2I5Tv=4WF^G zMm6~j@_W>Hp5q}KRxd4l<{#~O#k{?(GZhY8I>dN{eBrpYa!>2bh4^GBw%hq2?G%2Y z(qA;xSVZ~|uNeBS%`1L9*0_ssS)+!RC38Mu)_ikQj+#@Vuv3${(Wj$_Va(sAo={&U zt-`}U<5TkH^1|J;yR$g=Ps$7b+@Viz?P{HQbqw~oT6@`Xz2N#zw{dlceqQ~iwI}HO z%+mjbv-feLw?xuyusD>+4_bvG6*L;T`Jw z@-KEA=f{!F+rhglaZY^1f{P*>anD3=!%Z5YhexPG&gzR^GV5zc+q^#Sb?9H{5nho* z+Rk8f@YeFe#lUDYG#9!rOW{UCm$v(|wp0FG<_8u2RHETY92=`saKsP2Eck-Oa3~9BbW{7Eu+=&&cjkOZxGUDP zO4;g8zTDuxg`QVqyHo1wT3LkcPD+pX?5>oz`B=Y}+6Cz?E_vUVKDM?oeXQ8SY~GZ9 zU+up1`($0Vc|rQN+D+-(wjE0!yB|4ui;|TS6&*CP*7VWG?Ik0|N?Xq^zLGJT#8~Aq zX1U12f2ox_SuzA~x21{f#of+X#sI$Oewt>?>CRm_^f~-aIUnOVi!w8SA-EWjcjz&I zjtd>d5F_gc-V-GK0FHY)-rW*@{SFWgrmPm1`oG{!$Rs0=^%H4_b$dQq#qKT5ZF1XFJ2Sh zLh*rD z()WbD*QMXr7Jg^?wzl^p{aQlrt*`6couPww4)8AmJd6Y%xd&%)7ysF%)?GL*`j&}( zYz+6ti9MshSJ|&Wc)76>I=P*_&6Tchl|DWFKuP1Qv8C9C$$h9X^cVg1;XKld4M@qp zS@)GBx+O&zx=k?^D7;n|F zmhkb+FR}(5&mMu`7kasU-yv6)>S>jC!I#%LVh$IxCwz~?Rhh1jE0sOl)@aRa(8f| zr}m|fmHW;^{7%Z3c|+z4dB4zo>d=eQQ&hm~rnm7L=SKhVOC>`d(K^PzN4(otxS9A1&FP_e*P>C2S9rmH&OB5V zFkjzzvsdA-(1aSB9ig%Dw#{u>eHlaSJRf%??>@xcHnZ@ju!HnhGG1acC-|Sm`c-U$ zg{CE)q*)JsTuz$h(3OO(fc7Q)Ah zW+;BEYsNTe(}mANXF9MDn*{pdbtG5n(1gsla=tu+dufuj(Rgda&88)*9n zjMsALUWLPpBs`b3^eOP-67qN*__0Sj~kjhnrN@$^Y5RJvpW~`ozX-d@v9}{BJ#*d@c3J3OZeAG zaPM32Qs&E(;NQ34rtpT7;NZ95r?l}x@<#alnF(AKt~7^m<(wSkvh8UF7ewxIHCT76 zyY|ewPwatRb+fjKt)uJCSuLSH)dJHHKISt%5-)RMCU+YM9A(~WySwekIX`U`A)Gzi zc{?(7y>enCchOKj=OmB5z|-Be<>f;W`m~99OzG>`A-LxEo#8r?K2~HwsdErdJ*wpu zX}5C^{7ZN4p2++owoJ&kvCci|V+EhY7CTqc(JyD+touUgu-UQF)urEu4fd)y(v?d( z`t7`P);7{nw_Z9olsFddr_9IR%&}(|?@+Rzw5ypuz-LS6 zpx05nBfn1@?}&i5=Z+j=94%lSTYv-P11K--tjDB&M5ZQi9dPcpO7(hWDM@?T&i)WmxHx~ z9_SPYdrde`78g5uks-uhR&X#0cnQuY0f+xb-n+*~Rb7qW=gb5+Gn0hCH=FGXJ0I8y6PS@nj0iX65$y!B1UpxCkkbX~WuJiT|@!cR?~!z0w+_>SrO z%#Q!%`*xQU;Ucu+upeAVX=9;!T&mRg^ZyZfdzeL!eYx}es5S} z>F~-sCLLZu?+P7WLAMDVUO|@$EP}JpVsS58$R5PW;aQ3n$$isedhhoCzo13-fj)4b z-Mj5%iwDGBOJB^jaGz`8KG(v1F1Yu}S9kTUGnSrT%~vKZ&p8P#2YeMR>w35Se@)9n z$kdncl#I`!@eg7fm2UrOZy4Orv_wztcm};I>%7F5m-|z>_sPAoeVT;y8Xu+JT}Wm?W4jy``__AYHCzGa^^*fx+{qJhklXq}swL!Y@8-(AKQ z?8DaDNX&}&`5ODFp2xcy-g&=E3|jMg7^79TIctaNoINPt%h!wZ=zsrTs*9EuSas9^l&aboJKx#y>KZlPbrM4h^4E%8jNk#fLJiGZmf#FUfV&&?Td`pSv*#2@fhO=rK=e~C|z9(kFCabv_VVB>lc4iXs{2QqiOI% z?i&|hC-ba5_(1mKl<^Dw8#*A8-;Mh$BAGkN7};Z1(_cw?RzN(S6o-4}MQ$&fq{FeWG8~%a#wd`b5UB(kJt+ zKACUz$$YC%=39NT9GdI!q|ijkKk^{FXyZ`FzUNcMk_+=4&N#=nl>8|ct}RQSdQm9C zHUEr@LjUHvAnl@%$e`?7ehhlmW%%fUVM;m^Z#3aX5l%E*44LW78FN^by;3=8QLq~R&i@l@mwqsoz zq`tMxOACIr8C&xjJG;iY80#6Q>oYQ5B$mQjrhOw@RuK#Se4FTI^*{A<8T+;IKSNLc z+)qwl!JqX?;*iB{lJmy;%XWCh!94Y4;8yUgUb&6oWJR8BlV|judWO8nv){-w)rXWrX5`r>d8Ya?xju;8$g`JX z>J$2t>(HYW16;Z?r3{9o(XnlE<$C3vHn z`6gKxC1ul(ugd6kpW@yp(HjEzkRABi6-{3Ciu_(sOV!Y>l@nOePiRSrLqzbaxI zbD0N}eDc{2x0<67S%0DryjzGBsd0qs;**XeWcy7@*Arxzp;w9gweS#|oTQ{qbw`k?L9)w8@S zRu4T7+RGS-{bq(nnhpc!u+?r-LpYe}r(WPamU zj3-Mt>*3${B_3jOoW+t;K>n0YyqdlA-x-K+wzqZ;r zw4Gw9#= z4Mo7oShE}U-|(IAk@#3!iFesJ{1yGRhPisl*RJ9``v9;A4y&C;#hZ-3ezG9rikd1D zhehDPm~5liHEDmg)&56EOn6172)qj%>GgL4ZwK%SEbq!$jO^(sG#w3%$d}BKSUL=S zApIF#?WycqP}Z5E_8mzH{}PyFtP^xdzJ5wLhx;R(iG{v8C^7#waz60sdhB81vgRlD z)27I<(EO)8>PZf#bC18mIo8$h;Da6Fcr&XlQCb!-eostgd)d z=KZeitt-Awtjr$k{E+%3)|tsTx`?R;Jc>J#aHz&w_~rW&0Bi6`E6ok=bg^l zC;H&MH_s0J9UOkL_~OuCxz76H;?Uo?F1WKG^a0oWFA73`;dFC0%;fIVn^-77? zuulqc5Q)pCh9~p<-T|GtjH8zsjE#&b_8u>ZgELvH9Nk9t5X;{qA9V?^)JN?xBW=7% zUD4$m8M{fjsZsu^l>2T>xi-cKQZ9P`6e%|+rd)QSS#ESpeRE^V1<}7!Uk7JJ+k4{9!c?acQA9|M|abZc8#axA%Y6o~@fMw(;9dd)Q_8 zCC|XFNApY3E(^aY;P;Q7?b*6X=6wz_wrIpplm9Xfk@+F{-y#3;*W|y9L!xE0dwfu- z_43QS$c!7_7ke%;@UkTXFIzJ3vLypATQXqd^Hk4^m*J#O%m&mytwQtMi6UDsIGt=4t7b#Et`Ti36%l?CK-iqDo^z`1XX@5GlBKjK&D>shQ#`7_r}ayfp3FCe)q;-kx+T+?qv zzsb7$5&pdFi?neXXDt?W733C8&FtR8E3>O>d<#66eW5l^;Jz81h)=Ucntn((h&XYJ}qq{I7-`n|*manMtgv7^Sy=q*3#X)UFyP zFp2+JsaI~rr>f1@#%0uzH%u&p+@s_cY~DQ9*2{Z8_QN1<7|i4Tr{o}E<9BoOW$SfM+Ftc2>4v>vHA|9UM4CH=ngTE1#j<`xpE3 z+#Nm3xfk>-cLQx@LbKb5OG}QBmZ5oX=6|&8FgMcNiBEon@%&L@xJB8PeXP_?`xvFy zha*d}cSQ8}ZNx#9-a6gU%buoDz6jb1-i^d6q0zxoVCVjz;EFG<^0^wZQ9_S4`dP|K zZcKC=+38jr(|flez7#RBo-ko!6-;;aE-x_MOL^ae`iz3(O-_{4-@o8H>XWX=}kUFCA zVZRm=pP%-wQ)sr7dcKJt5sS|z3m?g0eWiz8Fz4tS87C?_4(i>mjmsF?J`9hJfKR>& zP4i=D`nUx{CNP|grY&|Hu@mwmiJm%H{({g!V7Beq$;wK7V$%fXe^GBL zym%vczQXkfTqkgy!`05$_^LdGSiG-^!n^GxV}=%+{$(^!6CUTwE8Wb*tmrEAl|E$BIg^N)4Y2aD1>;sYt3 zAvPuPBjGdAL$(hj{51Ei7Z*wuXB6;iCz`%H)J+90%Ol7=p@1c6%56xJHou_&Pn9KOK zjXXw^jKt04%*#V+4MB1^*rPu7`=P$^=mgP&24m<9@|PO1 zx5OFAD`(9|RKDE?V#h*{t7GbThdRWUAzt~-Vj}pE-Q!(&UX9mZcl7dG)mf^n)sR@I zv7;mpJk`0X5>IR+cR*x8Vv4mYkHCy8;u~&g$Ii=qiR6z2g5_$jENG?nBJjg+>{F0G z8egnL_f+gA?lU^h{J)3)l2iHx^b()&*loYU7uE4e9r&hBd{oxKEFiailFq!f>`}_O zgV2H)p4uB){$t~J*y~PSwtTL3_`~$M>Q-&R|3csAXY(!Bd5^Q!HV2p%0xx5CwFbrZ z!Ngye_pR7+;$1nLSyPw(%(-U#!Ra*P68kyhm3YCdzd6)=w9KniA-@t2NmJXPw#tdfOjEtL) zePPbU$+)4EJXbSME+zafJd*89sV}2lpOu@|2HrBhuaAlYh`lTO+#ELqE%^^3_ags6 z#y%qdHjaloyEgQNM|Aw*+vIskY|QpS-n8)8`eoM!1G^C<7pN8=#Dw3BHMJbF#*Zn^ zP~Z^bxhUOb;~}vJ!Q)A}g2#{L3LY!DN_)o_Oop#6fxjlfXA|MKLdHICs9e9V=07v< zPk7~aXuDbV(PP{oK8B1fZsnPQ-~VnOJh%%UY_WKd>%2zvShlW>&lA744<1zSM``0! z9Bj1H}KWNq=s8=h-;8}C8@9RRM~$!B|Ljz^~@A_ za`#XZ?ldi>gMD3!`|8(dKKb_i$sYgEs@Uq+(r*5ge6O_dXvLptJ@_-5apqeRi@V6# z%dfZl*ARaE8b0mz>p#^iHGHh#V8J~tycXQwqut%?1uZhEYiZ=ArKvMYGCnuQD5C_OFm6HRxC6-U#pk+wv>qUAyS*i6b1mwxAn(&wBlLcg5or?l|*xxXsG zTu=7~@X5F-x?b#GpZa1n_1b>XJ(j+>$I=(~So-1~^u?duVpmVd+J11AIJkd5@(fLa zmcQRa*5C=%7e73CUx?l-L$1EL&N6x8k5>D;ng1|#R;uEO=b%HjvnNjoO%)supB#>4 zizfu0$(G#9|B06D%l`uTZ|Uj_<^R|4P)k(5H?dY;`fn89-RaVl4zz7|tr;8Xw%xLB zN#v}UezfyrY`q_(tg^jpoxNe=f>V}ej7RbI(-{EZ!Bw#WFPTb z5r{S2FBg^Q$kPqA8qrMzuLW))El!Vs1RNMs?zOUuW96bp2%T;jBjL} za*(;H-Ndogn%ud=krN|)D|9a9`PE!Mo=rb~=fChn%-lO0#xiWU_zR)~1Xkuffwg*7 zJKu^kOX`4(vYA$o{$OKTH$KYDMn z{lG*Yy(9kk71UX(_92vc%AV^81V5>Vyc)&Z_@(g6idEmE9f4_MPM2>TnVny-SJp0c)yrJ}n`_U0)zz6l&BWxS-FJa`19Y(Y zO=u|bNNF>A4u5OR`dR6Rv9u?2>q!&wu}uDMoNdl)_H17ZCm_Sgv(SGS2ry_rL&N^_*40@ z=VkpA%~?N#_E^)jliW#_TO;Recr^dKANJcitDXS7Qo|>ZN71WsPKM%6b^h3st*zbcf z))6^8)iEMZ>K=dLO)}a*1|uF=ZZ#_vinf{g+i;#H}An;(m~Mk4`RrD4F{d^PY3%iXTeneu#O$ zR_=#$e~R_J+-Gv1WqmLAr*VI}d0!~-XLIi}?-f2{xIfdpmpJ)D=WsvPyswe_aomr$ z?#XR^D3AMm^S)5-FXa9r^In&GKlg>^{Wjvy4_(6jWb1pmFXDcRKmT(0S#Z{Qb{YRC z!W#t+==Moh@on(a@8G8c@Kao>me&P8l^*OWKIlw>p9Y5Ok?C9EAsH_|!c~0YUvL%v zdYh|^-9F#bZzaeaiV#D#>%KZY0H&<3;$ue|nISFw-pE(3x&4UW7A>yBh25K5ep6lwzmLfoTJ1=wU@oTkA^7Yw?tjO1bvkQE zJG+X7Hou@fq0QS|g*KmY71|_1Z=uauTyLn>X8i7SZHCb1v=Lg~X5bK7IJkaLa`N>U zW-f-q%v)lvAQfG3kHcC2K6Bye`<=uqyv%KAVR9CB}qK}#lP*9VwG7aC{t-1oioD}00QUEbmJg#*y9LQk%rP0nJ0=IvO_ z91b~aGpe%8xF0#|Nxq5-XfOMzXS|o<(guy@jJwqiPsc>YeHmY}mnJd0HfK(leBXN3 z;9?h&=|lbCGx#KpiSw6d# zGwu?ePv&_X&-?M*d2kK(Ei>*?o?piE{yZPBPWIsC+|!Os@*p@*ZE6;KVc5J&T%mF; zvM2KdV#j4&(1g;sh6dK#e2-_<4sWaEoqh3rF~{ zTieJhe0uSa&V8H*v0-s~=SRb|NRcxoN5*`O&LN$tRVhzq4ATyl-2@MG6}~&9jQb4s z6&$Tawqn!BL)e)n&wh@LqmPcT_pi}4>D{!2DNkO(__pn6)w^x1F}n^~6uA}uXCr$s zuED1fpYsdWFF5`<|Cq zD!fg6d%r&dOrq;0Ce+DVw%z28|C=`UfS=u_(A>n=Y}3YfALoXc^zK=&JQteJv)Y#X zzS_6TMS!8vd2MGAJ~Vt(@8ehEbajk8ujr@~+1|o_33HHj4>FPuta2~1-9CxDP^XLX zuI}<5LWgXpyM79OUk<;HT=vR;uxj1JhpX0&e7Inp|G|QFz8WnQ;8!sS{}LH(r_5@6 zi{0p+c3_=Cefu)}5sBHiBe#-Qe=h&qph-Lb_aV=t@ipW5jd@V(^zdW<^MkXg>nzG; z)2_5H?K-S>HQF_3(`s9_+jaqT68!;=9sF-Qn>;;SVu719M?yXJ)w`xbqPO9 zzHPp9czuR*NJsDttyuCFF~~6@^8mW-?!2CQV%)% z^#x8tt*P0kXfHHRWo^LljvMe#rSBIJdvn1H()J|PFXU~|H{r$CbUsfVRqUOh-icj+ z9xdo;sdFUsk$Zus0(fMe&Hio|_T)8qN9t{Z2jrW5@I@>8&5VJb)$HFtC&9mNQG&Lv zl)e8|Kf*sc^lzeFDa#%W9fE^q@saSQ@R2+_8NbLpu8f-m-VWOJ0oR+fok@EWX>TO$ z`Dsshr;2fvv}g0pR*?aVZw&ZmAGitcXI7LQ+g4t7EUUtIY-_ns_(YA-3i-AKKGDEa z_+&e{?Tg|QIrk--_V=C2*;~Nlfmg=xi-%W6^Rs#UJ@UO}&6&hCg*U`^JVL*v!$ZO& z5yoD%_^gr>xrch9b>Lgr%53Id*U;}x&TBiH=y%ujlqdJ`eHSw5I4^LtgMM#EkL}}o z$(NBne;WCh`LhdYtDWcjc>g?fmv<6-5ZryEr5<;E8F<-sMVEaByrmA2aTk3fx*;BV zeabg$p!Xu^{WSEhhTgV*5PAz=Nm=ISI=13Fi{6rUI_TSJ=zuf%O-BcaE~v2N={L|v zcW)>ZGJJ-@|66Qg)&HRDxjL;6um9K1t9L>y!Li$b%iJDAYm+87FHG zRjr$YeR4cfu&xyQl(Yz&h@Kfc*tCnQ^?@C0=yM-&k7jO4-yj#Q?>UCF?n3Xpd2ZdRFNPs6>>t+daJN2Be|eBU>96h3HN%f1Sd{|RMJ<=HfFOmN26kI~}mm(q9f zTr-^U9V5q1KRO9o+)97_f&PjI|AF`r=Vw*k)2{|Ceq$PIL0eAZu z;>)=@&<)r|_EKXli0pZ_hGA0Fc*SQ`+zX!Th;s-oZH#lK zpfg0LvQ}ZkwZJOlrbfn0|3+sXBIYT+Q$A%D@RKoN8~S23@$W^c~1=GNPP8Ol#z8bhk0g?2W&elegtQMRC>s_5q&NECq9OkHs#umoiDe2 z4DnCJ$FTgmL z7utz!5{-LX3d;sV968=rS6*DLUXo z$k?{Eyu5P^^6*x9PUmRkLD?(%LUfemsdYi?Mriq=WhdpmjB~{glXH;TfGfHW=MXQH zK5U{L@sWfN(iz*fK?_4quNOWLyoT}r3+gy*^)GA9Lej@}n@!NZFC6UteV)CsvKeca zIgM5sv(Oi<0rb-g^i3^s5b^m%w@n;dbX0sF<=diD#aFGsCwPlr^*8iu#b?rJ?>)Y0 z&+tc_^g$c65#RSs<^u5*Bdrf9FFZeF;i0=@WxSaUJbmA1#$S0Y~ zdgC~?-q;{!M=nw&b9==S1CczZ8vKwP(CMx@!IKitE7yJ zG6hx{*C7Aq0^Wy-!30iUJ=8O$JWtMBWqqv&90zH-t6tzOFyL3(2cCN4AbWCxPpOHI z%H@aGX1cIBe6w@3`meDFy*#g;y4Vx6ek$<6Q-j>-HkrSWm}5Nu(b4%O)RieV5;(9i zq38S6uS($E7Rnj$q&z3juXqWtmKWyKd+-^mc$W?D#M8!V-cRSd@aaqV&aJ*Hq90d- zV>{QI`A+7*8jrG<2J{wuPa9M;e>d@G!R@Qo5x6t`zUj9t@p?`x^o`N8-R-C9zFT#} z{k9&TGr(2r9pm@=9(8S<6`$m9{!~x!yr{=}&OGnYnbW`f{2gaG5sniB}u5vB$<_9PF&T%Ap(i{n%1KL^cosLBJ zsg9(a3;dV(E;Y8!+I#Ct@7U7GzSlL6=QoYd1Z##0`uR7D* zMOtFc$h^yZchXMHZ9nne#PrMd&;}kp?k8ZD@z-~FToQre&ijXU+f;|9O3>E?G{t+{F`gM zKXDBA{KzTo4)q+R+}}Muy`+HE|dL9>e%0crGw%yfx1A z-S<0(y0-uu8~ z1b9ffiBcbUTnpWZH}C@w>JvPMTX;yhx8Xa%<8RPo0p$e71(Xv!uD9^`qc$+GUmw7) zUts^jN4;gYJ?WKiJ9WW@y1|8UBJvI#`UUO_ox|KuIMZ`};D`%6pe6Bh1Rl8W0q=#k z)OlBd-!+N`!xbIod!F=O;Pkn#ce>pFa`X?pp$+1f7C7T!yCBMq4OIUxbyzUyn?fQH;?|yhmJxQ!TkboN^rUY>vSWq$&nT~ z;&Aed3qAeSh&UXsk6Fg_m{prjz|F-UqtDR|{BNkr{)zbslJBI}xcccd1bM)ia zKk(Q4ANL+cmar$_J)B>v!vAjY`J2NC+~-USe5s`bHaZ;qyn$cezrs5n{LTI!?n!Z` zcs5f15#*?odeea|*^wM*a3t~@68Ope4c-m6uJG16Mds4^vDb-(`#3#~GAV&G90LL$ z(>L&Zpz}WD0^C!bsUGCagKQ}I+u&Z|UEy#6gAq7~zDNS+B*(Bo(*4W4hmpfHN1EqL zWN;&LEAU9ar|QYzI3R#d35*5Tv5w(^vG<3(n}EmX5PSvKG==ZS$m3znpnOVTt)?^F z_VP;)WZhTe%?00a$Zi95H_%Q4^=|~8SEw8N7?`9v12Zm`Q*Us@ZYoG`Ydogd*6fJH1PeoCU~auOY3i*zLyhmni3!S^o~J%8yK=-E$yO*9sHucY3Mz;r?$1_V|j z4 z=`QzheVF@fhtNNjU!2lSdFZlr`tY2iz$5bf@RKWN-tys#cbGcI97-lc2hT#cxm3RoRl3cGjuqWDl>G~4@y&4O5Vw>u7JDRee*tt^fc+4M*;60!8Z%{Q@h5t#n0^z$HOGdZvk{jBKX z_-eX51}~fTCRN!RsYmEirugOLbdhqGY9h}kt)wV*918T8m#q zerzA$I%iM1nELO8{0Uu5f8gZ&B6X9`(L)Y}F7GQ_|JD3*Ha0j~78SqfqEAjvmst6e z_JuA(Eq+1HPAW&S@~7nJYxpHr_QYS1HuEhx5bZy$1D;~OhWg75r3`rXDiR(_Oz=tFPX z_{PeP;QQk~^rek&th@-m*Yu$$ZG2$84UuCDn-#@|UHomd4A@~|! z(Z8|skOIE>R{xH-{LO3O@AK(j>{z$IcDDHU_%Cbm@4i6J@1zfAt8tgf&t829Ixi() z%fap9N8&%8%DBt4lU`)psqDcs_;)wri-;d0{Qom-Pe}Yo{KwPi-?6}hPsuNiUxFu# z@z-hS#0NB2;C1x()7YEuSn!OzHRNs6MgNO$8_#fpE-Q#S(Dy17+aG|Q|Tl=vFQ$StQ_g}24~Hs z>giL>bylh2c~_HnOLDyd$mmIr?v3;%t`C!YU z_c8XZndmbZEBg4go#F7Qv%d7y=C>Wy7s;N?s)#GuakO&cZdJseT9`wTcQPkP>~Cg) z?&xSChE5(<$6~Gl-bs8b`nv!zEQyg#%qlusM473?$Zqa8ba0jKtX#_38aIDe%dgj+ zGpZt5<(bs^d{*nKPZw)1ovF2bUZE!}`N5C%? z!i8hH>1vR-5%7<0wfk-NkIdNOTNi1Ama?Zt72mHOuT}0Ph96ITQtv0oRz2@4r~1z5MKT#7skTv@<5J?h;f^w-6N4CuO8hrQHO60*lO(2(B`pD7cE=vGEkx;<;ZM1G~(J*|5uZ-SyE|!rRZIo2L`E zt$;qyLmP?F-asGAd`mW0DHHJLC>lvDS!9QPEf!j4@=o-$@Z=TXDrMVP!yBY-c`o;I z-34tjscRRsspfjQ;{?Af1#jUO+2huxl})E*JhN#OOP@O42~DajJna6J_UtoGN}1<2 zkPV4@*8sD?W1szz#Tvnas5pHi?_3f~*If1Gz*L0nl)!HWXW?YP_fz0eH}YG8{Ent< zdDfe&I*{Md$S<+-C6^$-}oB?m!^%_NGd2Z^=pw6PPocSlR2oHZ< zoo@Ow0I&I7)8{*o2dRHCd*rn<_Q+W1tUMEXX4D`{$V|pI>q@&dlr@4)dCXTNOPgEe zeIatgmFJs>^4zYwWUN*>g?+Cz&K}fmKSjX|FI=nY1&tTU1F2DEXx zs`CxqKQH^Yx4-O?XTc`57qJUCeZW~jT|sn;k>Fo0dKX?%d}7lb9jf-h5&JB19z51H zzKH(Mfu^!Ar|=YOZN|y_Qsx_^d?wcq=9r~|4iANoq0L- z&M;UnVA^lfUopbdS#P5q?;6*}S=TZ@y_R_tbAHuam#aAo-qVhCjcb3V(04vQTnl^r z${vv}cv;$-&76$v4k z=wdE%HhD+VX0B>8tw)rbbIfa@Ix ze%h$(9Lcr}?-4&w=%1k7Q7AlpO57D05$AXUCC`#N3v>*H$CX-=xn2Yjt&o znweY6`3uFY_wvhL{N~v|(w6;S<{xFwOz!s;$0c*N?6Qet5<=?ypHD_8xK86dWA;&R z=nJm3$h{5M8rm;%o`5TdHT)&AR|LHNJd4sQYT`nl=snN!vvIJ?7Ey;PdrMqsDLMYp z=l*4qyJ5o}jRPyBy3aMzUM#t{yG&)t=I;Lozt7&aCjNcl_yDx;500_xzMy|ZE96{_ zgZPmSf5gFm$$hrhi#-R;wlbd?``t=t-o`q!x*luqPJEXr-=VL&7VA4tws(X!o4svl zSJthu5Nks3LN5JCstondWX*Z|G6RK_^y`V-FhUr*Bz=F#e2Q_qH4Yy z2~5yit-Y830$(F*S6u9qwhw#Hnn=+xa<=WNCdL+<*ef9#eyKy?H-JAllJ;}>&BFhX z_GfCxOC8R2r6a2^;{EdARlMsjbL`cFgL}1Rg=fu$3bv}J6Pv1@j&G{ERg1JiH{;sN zB2^<@P2~4CyB0dG$nF<(8#*?@p*IHy$AvUqJ05&GE>t$Nf2isg;8UZm6P&7^)0^3! zEfTz=zdCnY>t8*y^#FV8(gy;QR!upLa`ywve>_d4vjiUKJtHZE&0FTH85nY9>P=Pe z=*?B1X^|E=*QFn{hem>@UldoEg_3ewcoD+qPMiPXx|ge4hgNz-;D53Y z_)8P@h~lBKg}q>Vv=7++D+;zBMZvZbK0GqV2yFtk-OjB&VH&JQ+D5S+I|lcA`l$c8 zsQPbAQ~R!Wb|3XFi>mip>%H{jnnl$YZb{T4o8bRFz`Zt_mP3WljZoQ0kMIe6 zq4RUB)3!Z$VJ19s=dHYd+ReM_i&*ozLHICvp+BNIt7rOYL*&70Xp!1faVP`|tUi$k@>Gz>cRlm}kWn3S-9|}6uI!8uyY40%!4mnSDV@pMU zxv``2y&D@UV=XuK6TLLE0)8(iKM%UpyMYVZ9Q18lvZ?}l6%6tp{NxPIeeXB5{K`Rf zt4am$10{J=ti61Y6VtXg*|xo``%K@f!MmI~!VY7CoPQ~PnB3=ZKU(g0a0ZLK*Nq91 zkDJE+PU=1Pr^-EhJgN7ZF+uX5)7a}>?w2@?+#>eADuC{O`a{l;g*S^;pJ6+U9h^yV z{PK5;9NI=%ukHZHKkeySS&FVM%OOwB!P*DLeGdGbAi7)nhW(^E<(pd0ni3kZ*VJaY zH@KHN#1_A1)ggOb8{iM!)p@bNQq0)rfbX|)YQLE1eMt?@i*$jfjAiBgD38<)e4P8J z#u?HM=R0m@5A&(2UEpwmC-<#Ex7gdXdL>^|STlqlExDhW=w$F6Cq6+IIMlk8KUtZ{ zdJtt_woOrX^exUlDWwkacZ@6HF}~aF)FNs;6!eBN!D&~pYvo*E+6W&u%6akF-+1<& z5*v37dZ-j#A+jd_@qNO!@8jca@M>(PF-Y4Pz0cKZY^TKIgvYtglQF-@iky=o@QeTN z{Sd#4XX5ADK4K$yC?D|;HXofh8#)_4%Ay|Ie-PLN7Y%xf-!@n`_i3}?e+wP}W)sgA z6VGSCvzUGKYoqp4sin>BQTv_wq;D))F)UdzELky-72zG>n~Bsfe4}Ws4+^1%GabUxSZ}EOOq-<5s!CK_NpQ*9qSFjUFR67JrG|DE#;Ijeowt2EMWuQgY!B z&FIS)O%C6}^PuSNHPtg2qwL#5tj*Bxekq6!SMaWzsQP#tJgaO#jj4OKe1^Y}e8L9s zu9poUo|s&X?bNoqLfP;`^!LBy`%J#EZAcNZidY*`@-_5TdKkY$##VL6v-I;O@L7ot zKY~vQ&nsTsXyVR#fN#cwB6DJ6UWZRaX1;JfCOL>F<)`~Klb@vhm9!-~S^OUPFESwQ zuIYpK#cyo1c%Q57Gwv{4C-&DBo}CTvt8rnQ=}*;)uPZW(9#gz~wbrD@ka@|WQeZfI zQ`gEr5#NdCU38o9?lOC?KG~lG`t-2RVrycb(SJLn|NaYHvUwlPQza&Tt!>sB1AqN) z-+Kf`w<~r;>ah0+yga6k?bH#?kF()Bn;#Y47C-I*AGz1yN0I4ArgW`*89zkqc2LvC z2S@qy+q9(I*!8(x?8zr%F4Kp&gdEsO5P?}t#&Q0g1T8sFip@g1PftXs1wSH3_#wa7 zcW#faUbCr83%*k4CT7Rlh9>F~8IbW_Hnx4ct9oYB7wLH!x|UyuK6fDpIj7L( z6^@-tpb>T`6iBR|8GbD_&o@No5s0U8FLjCCm-p?iq}+1qmzcDCCv~k|?AR%|e{Te5 zj4V#>to^>}SJ%-GzK1VWJ{@t02;&j;-@wO{b>Bm%Px_sh$~(0V;Bcm}hkkN+Z3^`` zlhywZ<^N)DC$=d<|7|b^`I{|X8H@}?=D4&S6Ym=kT639W=N^2}d!%3Rc~n{2k+^pY zy4jemhZH=FnMR{ymzJ{*jO&uY{>ZYyg^`+6WNT<)V}bI7`~8d#SMbk@@UZ zGS=Tb@YTYmu@4kBIhGbSJ&5eB!G8BQw86WQIP0C5<0^xVg9?JtLxy(2`;O@M9(!H$e7?_4>O^;+)p`h#)K*H zC+^$#cTK3X`b6H>?H};Ul&J50`v<-C>wx_@cg_@@dJ0rl|VcKY4V*l&JD`pOn1P6jh(^liN1Q8hX)B1~{+5XA$06gP$UP z$r^kVq3aralQ-DkVpqJmf3%{V(XSWnp2P2XmO8}#_oL2-F7-$H6GwJ&{~Z3$v(zhl zls$+4^DK40uJxNLW&XuppKqYo-XO;Lqs#q~-*Z2J_|?a?Lqb2(J10DFX<tTw2ApkrYDIz6$KO;lQsU6I)I(RB$;|H_DOdI356q(qCnu(-JM%a`3v zox5C3|M{cQbaelKrrj(1H|_m-zoz}ak82WnZ-3s|)UnCY)W#b9Mr5Lm@l)gCD>~ae z%8yx=wJe3T5XGHs>W}(ln zMXz1Mnt-d(ivyI-eC_hMhAyXLC;8;J9J|fIdne`MDBq9o`lI(auk$rsomF$JU-jT2 znR=*EWYTx(ak;wAy!805k(~u+_Y2*FT(#j_F*Z`?Pc!b9@z9Q?L;CgFLs9m36&`D| z_Ka(@_Ka(@_Ka&|&$xR>G2S9BJm)@qEokpz+~(36?+~A6+XB{0;S>03CP%R28_;>p zz69qLOA;9W6;6|R`J4>B>hseW|IMDyc&(PWm+o3(%-1#s8QWzrwqrb1EWU2_1mc?y zXye<;@6F3-VsDM{++%ZtPixKd8TUJlE-u zyyB333sS>t8RO2z*6qFuU8k`>7qqZ_+icoe2QAi>^;7%p^*9d_T1ZS6y`|)?$)ZV< zMUy6rCQZwCuH?5n}KC>$T$TtP6K*uGXR}c|SvY@xv>z9mL8@ zSR+y+xNc*t#5`WcM#jCobG@$3C?M|KSj?U=b><8XgtLLRV48L>U1Px0qXgBBt$ z9r*5J_bxt$$IH4taT77_ZIssj4m9<8r_3Hea+0S3*9#?M_yMylTZl(V(g>7J?U7M8xWT2JUOcvnH)ckA&I zYY$P^i_Z}67r52}7elRgB;MX~xvWJ>ZGHUm&P@t_{BGeZ@)+K+?sJuok#$)_%1@il zoZVpm!7}3R#M{TdUZ-E}Iz&{sxsExv@k$FXh(;s@{Byv9rm<@1|?^ZIF z+47Qh5IPsHnW=@I>M-{^O`oQP(&MxUw&Hk-U&~Lq#2il=MOuCvoo_rmG$eC1Tj{Su z$iKP%-8>U|6FM!UVtk&QL9iB?kaxeC9@lU__qEVYXjp5>R;?vlwa~Z;`iWevC61$^ zYZd?taWaXM9uGp3vRT#5Im}gR^L(A<#CjzjREF)qAF2;ttVK$}hdqRsU%!j7E${H( zL%V@hTRK#ozuokAGyY)54uszrlgXHGN5!6gp*nnz=rd|F4rJsJ^T;0pyrNrWe^@(~ z@i=wKctqfwVbLt54_F1xRN%E?m34$=z*;s+#-^@%;mhDCErPr(7v7Xu*HCog{O7dh zZSQv#e@5FGJ4`qv2Pb2F#=Qd5A^CrM#%B3{Q2rkn(JB8w^A_HNo&ahQy*s3#3o#ptv#D89#^5W`M ztn=EL1+GQ9&iRab$KID~SIJz&z1T|8-v!8Y_Su{f%|0TIx@{QP! zo$zkv1!6x^THArowjZ_0mi@T0Z~O5qaAjLI%iPN*HC#x2zoowFv$PEvT55*|j`$2= z*YT3P0`fe3ls`YEZdEdST8e%@pZi1Dv6YTga7a~oWD+;~_usl!%3e5o&eKAZ|7GHY z;@Mz)(R1JU?NMV<%g=u&UDm83f7_8i+Ln1U_NN^k$|lAt&nNOcWi_Npo8mv_%KabeXxVw>4Huy&$q=sMU zv(41-={$e@e*a^0(REutU-)j#K2y(CAr~@-vzq;+Jj8{L&?nO0D(({{=k|Gm#LI+I&sT>nCTgwH;bD{&~9@0U1~ z%qdG8>K|MMM&XyaC-5T%VAC`u{OL5t+rnGK2WvS$(HJtdc?Et^ZI(8E!{h$Q%fzE( zto;3_i1{9m+Js&=V=iS(K)=;(vEq~<(c)SRIrApQ(!>i)2 zl%gNS=2w&BW&11H@*m&eT-vw&lqpeuO1tH!oN4(fPvfV_T7%U(`Gc)( zDi!!C72Hq5Pk9(WWgULX7W|Y?@l(#kPZ^mt{b(aGR3Cey`MUj-c;%{ucy^Ha_txH?X zIP?07vHg4HBFLGuBkcA^#&@^xz&|aIYG3Z9{n2*&zoRUD3aumT4SFE!fr0AV-zHk{ zWhRB*=e^7WNZ(29Uwk-;`-`rWc)yE2%Q;@VWHh#`Q1n`q?Rt}WhePa7@H%DNqU@Fd zU!kPd2MSHQFpYBv>~rSD*J5mB(lXG$8<$1Eo*RXW+zT!wM47Za)<3ikSkb+e+c+;$ z(NmwJ;>W=+2TrJ=PGSUun*^4D)y+lZ9;5#&n*q~$%Dz^*QN?@o;bg> zaenXfOY89YMc2qVCZh9l`>ZP^yq>y{u^n~{^#$~+>W8HRRQ&Y0ImGycPd2zhcS}D- z(J&hwqiE>r-)k>)p`qv*#g9t{b<=PTH2c?}D1NkP_;Z_vd4ogO(~jtJ(UWbKp6qL! z%i2uE|BQ2rRXa2Lb+_{>eE(KdJ9005^|0N}C;dVbzhyh^=o;0|a(8z-6M_GwsCMLD z+PTwi=R!{?n|6d3ivBrpLhvc#bj%CQ-5VE*m9K1cEI#e}ic|WQFQLa*$$`W!PbLQj za!`vc^Ahomk125gmO=Yp0!`#b#&kCGdC8F7qDiDyhEu5k(RjY-5mCN{O7T6ha_ z4;2GZw75I5n-(L%{kAAt$i2|w9Ge!y2ZsKM|EBmWIYE8*X_6&7?~wN zsTLk}k-t#K`;o$TYs@(@@r92sm;xP&pvPs<2|XrB51(DX+2m-s(fC|p6ETbDHS+m2 za;7eaCuOeQU_76a$z0$uU2?CMX~b(aLpv@$htNaDb@x1jz5(|BvzWI9SIJAw>Kc%n z&7QPA_Ns*s4rc2$t90ETk#n(Xp#wg_i?iw9T5=5=8E-LP)7;XZtI%_{e;zh$hHJJq zZvyQw&$wRZ8J~g{(Q9O)f7O2mG|i=) z4ab@oIEHawMf`K_`TmHU#Sy&KY=beQ*#^(0jTh`TZavY4d}G(UB&ObeQt$an-!;#j zR_MpwfOJUPoC+yk|Ws6 zIJHvenKAY|s=p-Xzwr@?MPf%rVXK?`O>(|r2{yc%z6$g!ROcPCH(;gCy{ws#@qleh z&WpkC7r?0SnH?8$u{T;dIF{%m?l7>m>9iqd;f>&9wIh`(GQZ|BJQv$H607#_tu{qw{QA zEpvN{ZYe5PbECp}?Rg>B9z*3JAtMp>Y#Yy@yN@v%bN9rdB#uBX0W=>CJ(yQt?<20p zyxICsoSyIt$XtE1ze(oT{|N2nS=Z^zFG2gan*5Q=zg*h<0&|?J^~4V2_1d9g7o?8t zNPM&EbIPuTSGLlIZJ+m?iT_AHv^Wiu*RGu2oH5ocw>zrbUnpn7JT8RoiTHTuI$dja zZ5v?Xln-*qH@BUka%K)>{bZoB0lt#1m63bk^UDhbPvReEhRUB_ z#5|$9K4@(0t%m5{YUa-LrBrJCj~=ruR*g74u2k4$^A6B}jx(ZAW&hjxyJ zHg8^M4fV<1cZRdNS@vlbeb|7F+khV{_M;>Q#=l+`*B~*;xzugzv#Vmt?4(R^1hhj( zM(c$}_#j#@#QK4k#MHAz>KXa%>p3^3o^?{s+b8u)yY*BoJee`|JV`yGD~I-YE_up* zj~gDnEG`-TT}Cvqc^iGy#`o(O3&!SO?LF4}8NSjo@s&c5{X4PY1PhL&P_U!xoScf4rt0T`& z`D*xX!W-C?Z^awGje#eR`>GM-ufreTwytnYU8hT3BUwivcJCy*Xv3Iqrir7R-UYHcigQdZzW!${k?x^t;7p9PYAtwC-dwt>J8N{iCGPhIKp~~BkcWM^~{_4 zc>d}i$vxn?TJM;dX|8u{7#@1{jOv-!na>Ysb5pyYKmC)MnOE}stJVz4`Xr%|YX$RV zoK;nbJZA_`F$TJeF`=CIh3~&1II1wx!ZB-b%v_Xl3YGB|DPDj zJ0Cm%T`4!0Z~iZJ>sYIg8?9Kw3zyQztVIyJV>mU*0a#|NnAVI=8dnPZhQ{wqb9~)> zKi0gjBAy|(HoITrgmTtWDEsmt`*_}17`eH;Fyg^h+>AYt|EW>F&K`W3$HBYhv&oU# zV_h56cY9cifbRD2PIBMp(zZ|b?Jm2AB@M|PrOY45d{O~(gR+n3d&txn?8%wfm(|#p z7xbm8>bWn$$IZdMFvqYnN9;?ae7TH^%IF6l^E=zU+W5I=6-G7yo8s#_Z2SMA?fL+E zihi)^TP*Ztje+t>2INURXZ_(zFT+p9mL!t1`4#aTujOB5l9O-^`NC4RuXxT}@`r1o zS9Cn5Ey}+V8z^l`Y=!f$H*P1k@@5~gl@@HQ#8z4uueT)nd-+;V$KZAf_X@Aby^6*` z*1?HR8`I@vt>nqq!q#HH>5~n@-^TRrn8iCX)|NO9W9CT4I@2$6{c~zFzCxwzpPuGz zI5 zN++RYUQu5{r)S z*WI@h9mJx4sW-%)hiGK_^MgiauJjw{!KoPI6mkzFb|W#Q8gkCcpPSi)E{=GJ!Q4!K z;A-+c7~e}y+_H6*{X>tC`|``OoAcgw4Xocv?oRw)?|3B zH5ne(WZXPT&Li}+2FMjFU!_IZAiYEQUB)y4#(`6isWIrzssBAWB09Acnw0XsoH>jN z{E`&i&0L!~u1Kx%H@P@xPv)iSy3%r8aWei)4Hpa{XAb=wPSEI=)bN+cgv1VIE^<$a z)*y8i$y$%6gq}UmK!pF7X-m}}1k19JgT0ql&%7Hte$^S@U5kyKLeC{XGqjN=Twwv)@+QkR$`&{xr_1BW4>&&E1;^<~O)dN&J!+1s>P-UrS;(W<9r2P7< z+A-=~GVe0oR{7tJRnG=?m!CABwM0^WN7nAM)H@^UU1^&2Zupt%ozA%zuYA zUTCb}Vd?0#{X&eZut{2Txkqcbk@&vMl_XMj4fg5~eJ1~r-7vO!hva4WzTrBlS@1Xe z#w^Rq1pn9YnZ~g%3U%$_zu2FT`e_Zjh)Y&^89TF9D4R2+ga?$*xWlCJuPLAD(Z*-v zt9QR^G3m2G-W6Lo8mA~6f624bE39XE=Cd%*w$4<~@Hx$TpXJ%^+pKasQot)WJIGv@Q{97^LlWL@T+2Mq{KP12I4Ma6Pe3_fP< zjm#}Q#98o?hiBJO%NhVx$Ai7+fK$*#lj)n$Q8ZVuTC(+h!HNBXgb$Wp!vAb@t&sc= z!UG08V88?TMInRr<9V%H0uPmV=jbel{YrNesC&Cj#lnZ0TBLLW40n_=PC-aj;Y zKn#tbrz?9+cdY$I`cC2uqIXlQe1#M%Um?ZHS4bgWLE<~&Z`}W`Ip>gx?^A@{uPq;+ z*GO!ljyZOFPAD5cM$z}jz3D6Gc-MWNk(Wt(B5S2S@|t7%{hmH>Dw=B5nLogsw~y*~ znYX_hJe2L$%U=>Zm&ASTJLVX=_9b%+U3-T)hOYg5MCWSGX0MIT3&1`pnAi1&*`}5I_To+CSlUssJnUd2{ua*0dV3w0_&fL> z?>wf~`EhpVB-0nL^@-%p^`*mSKl$}K{KiRf$yEc%?H-{Or_)APm)10#tIc!l*D|>4 zRIQo*pQ(IO)`t(l_WYXrTz+ycn}MDi@`1m}j0dHLe`aV6g4eU~yS&Fvjz5N7cQ2#=C}|F}U4PJ5*>;X0^u|6BV-= zKW(PY?bt7Aqd-sZ*v6jvZRo%jd49S1d>{9c|GbBNV0Jl}2Zpv%z8!nXen8{pSvAiD zwhKDDR!TeLrJYkH2V5J}2JUh$hRHWqD!!5UVBJM0j}LCoKM@~%3;7lOya%7&*1<|w zJYedzE1-iGr{s7?*3ulM>n`V6^=0Tk(V^#DsQBaq==KBZ5;%&+tj{&{G6hSCJd?Q& z)(K{v18xHML{kUoAyZGj0Dld%c|TID{8rJQE1Bo}72l5o-z?~~_Zic!$arB8aY4Xrv9n=2{){jGf{@6lv1d$3&aA1vhPi@kh?V0D zW>3q^wcq`hz#%?Nlnz$&GlB!W-$CB@UBL4mb-s801tG?f%dVlFAEVFn?->wU#dX#V z1443!i!G!7kGVIGtLnb<|Igu~-U}|MpahM8W&xK(Q&DJ>a>1lQ%uvPV5 z%LPq3GvDvy_s4yldzR1k{=7f$&wKwEy(jh@={?_r$8c;t%R1G^(iy~@ozFPN^^POl z(02^ot9;@EI${%UI9ZD=0=dbYEoC3=yC;OfleO@r^Ei*TFc4oAgfAWEuKRJoO!RS_ zOCQI%^l_X^AIC+|$5{s)`WQUfz3qofzK~sl{xy;|mT*qXHn$dzViReJA4GQ<3F< zI3ct$;#~tc*g~JBd+*cdspo{2@!d(*N_o6Bv!_p2ofEo~=bi9M%{N$Cr{L$ihge-Y zcR7z|e{%UQN2WgV=$~1iKTZ2WXrpj!gq76^jy(V#pATH4+cofj@4C^gUv)QQgXw7- z{_D%UEB|YE?=$$Xc|2<+_n!Nm;lIA5?*>Jbh5yRqS@Aq8YdQ0|j{bV>i|5mLG*-3w zFz=ZABAuh;eKS7dn-vkyJ(#7yza3^xu!i^fufNh+|IG0W)=dlR)X&;awfya2U-<^L z6^`e;Ah*4Y54yL1i8X&SZQe@V2LFJmzWWdE(|FFg-{_XnEe^ETu5tJB2tUUE$zJ=B z@9*1dQ}g@pwW6Ir%!{oVXwTiX&{}XdnMrZUqP5-7)59)4-2;53lPMkr%{<+m^(Hj= zIJmX_ZHIq0xhC)_e}(THzL(q++{*{;`>}eK*znQ5?#BgB(f>o_T-SHEG1ic9F^YG} zpiDH+T;lOIy9S4zdak<<&zu2G6K%_YhBI(U=VaGAlO#D?plo@;(ifiKwqig9Np+E7=Ys*3(fj%TuLHtN7z63A+5xp1v^Z)z(ovn=7f8VT5oi!%= zz`YZ!?G?-K?~LTPwqJ~m_X}3m3HYt>aBL>fhZ5z0h~l?iEBDpZVFU7LZfr8Tt8;bc z?>P<6F(2DwzW(esbN$-0&zkH1dA5uxqQ0H# zi&;OHhW@Z2rpD}<_Oy@(Rx4{(a?@z)yn$~s_)hz` z83#Oz*7KE&BW;8`Hux1i%hx=Ow6d0^CGQvruIW?Ux#{$WegrPu4_>P7Wa{25KF#Q2 zk{hN|Pc8a8^UUaCk{hP*4B9kPIDG*)t?}5d{^cn9NG@wNX|R9C$K-1@^Y6Dp7upW3 zRE%*6xwKm*WDkfli!N~H!A8ISUngB!(sHpG(=ggi3s^G;8qenQJ{?b{2CPKQ&kLQt znq%}`aQc||JJWwxF1i(;rxEnOE@t7QrJ85#I+Gt>bTIz(qJxTwtzca>4F39~{$$Q~ z9_xr_p{3ZxKjE$Vf z#D*rGH76Un(9GXJa6N=Sv-*9uwY!Xc+TbJfRNvxDXYl`e##rD>*io5~yrX1P#tvlg z=L|mF=fo7OskHT#^!O+CyH9Lj36ETRg{Jlj6V&-&1g(a`On=_#2YR2Hl0tk2$opiQ zePeV(uGTQm<}!x1&tV5+ZBE{WOho<#^ni`lF7gzPEx)Y$LaVELh~1Tu=L?1JMIP3> z-|;S&cfOdWU6M~K*^?xG|)V6 zHZqeFXP~%_v(2{;UZu)zKEQftU&J2Z%&9MQkqhIFF2gT0rjK{vo1l3{mK05F`6%@{ zGgs`X>TkfEt4hn6tMG>xKPSG@)OnRto_)8ceE5Tlo$~CzD&NJr(j2Sqj1PwFxScWU zzu&&@**8Q#G|w%T(G6U0B|KMZCGS{oCqGweCpfqmj5YOqM?5n$;V3cFBY>5uqpRrA z-%$s6yUoCs_k#Pa(2rVjH9PQj;>kr%-i4n0iTB|yUrie{I29ppqA1uWl0?|38smHX*UbdM)2YrFWXH_<;50Aq5kyonBy=i55zzvXMr zd6mAyzhs{njBO5^7P&pUc1a)qnzP2=h_h% zi#@zJI!rGPbxgxNo^M89hp*zcS>Ub4dOkLj&BS@VA>Ij_{p;Y4>RZNp)>C!*P1@r| zH;m&u)2H7$xG*~X^ryD-eu_v5<;A-aXD&}w=w&+6QKYXF(>?H-PlQ22Bb_19EQ>^h@ ztdO06e|>IjjgLHbhq-rfB(dR+0KQHGv~vjFYoPii6aJ&{$LHJs@r?o6j{|q&$+P0b z;uJ*YmRfyoXKt+3(M(;3!7JpE?PgE=@fB=IM%LSBZ${2p{k4AMJjD7{A5T9pk2VS! zfDh8EX$dbL(pmI+7A0-_SOjo7g{;kHbPmNAKrbE zX4Mdz9xp!lxUVv$a(7l$J+V!Jk=A``YccDYzGjh2cVx_p;A4lVU(cW5`9OW#Wnk2| ze}d1s20n^e8aN-#F>;I#w9dlFApT@@i|jLY#5{}MzU1!HIrOD3y-hUxiu!p8?}C0u z584lJ&{b~MUR4Wi=UdU@f#S;)S5^$)s{H4iJrXiFRCj@ipGl@4U4jWeoB#jH8W{sV zWpI{tTTCz~06!Mkm74wD;9$;+_$sx<1#{x?``ZIdC4(pPE5uYPR|k>yF8T zw`f@|&-Cszu({rpnVMdc*7p@mjRI9tax zJjCjHk}G?9(fO26`d9<8ub3*4{`dSQp>Q;ZfAFk&i zKYM-*%K4_gRN~L#$Pso+&b9t#$T>*b&1+nlN-)m;j2+zY2@2|o_uRF zj~4p>@ozi(BXI9Zqnyq(Qkg7Zb>dl^gkznrnKXW`$F5n4>WZ4dSzk7Oc^C;acd<7uUAWVv@)tLIUE*FET0*)Op} zg<9}sE5Wagd@ZvZcsJROTe}b&8S7Yh{7fFaJAU0o;*WC%azXg z@h4n)^uCd6H{3X~YJNC#6KsHfZZJW9Y|A* zW^Bi}XVz>er@kHF^n=8fKJg?upaRnr81u&GqljRvG5U(^Z)boWhCFPfhX{IAuktei2j7pM^^fj*M#^#?jP_a z?LEPKuJlqSL`n$FU-O@^~c#KW-%|(JYyuB zJf872H+St7QSfU1LTrs`FK@J)OB`B*-)rD^1t#{(!y$i1K~o$th70M_d*q}RPPL6D zj}>rU2%cMkyyaoeQ=e;FvoknLV<4aFQ+Hn86X)B)c>FF-`@w19vqwK=*Dy5nrimeY zF7}LnKD*E2-QaTNL%?0Qd=7iSioixwmz)17^F_zL=*Ry}x;a0(Wnk2p`MV(|KVD?c zXyty=zM$Mo(!ne5lJ|5Vq9D zoIeavp8v#voz%zH-h!>Y26~5&ZCNXGd^f(k72sz^tbF~6^QW#(@Zn#{ZxiV+6Xsjl zMk8&ODXwi4dz0DIQrtZ)#og0V+&wKN!Z!L5Fe%1YdThz(vyst50rq6iXQTx9sEzn2 zyJ!H|q?Tl4zfQmWz(@aaWEkrQM*9D%{sSlde@Xw35Ff$+J^a58xU2^r+sF7rKj4ZC z6VjgTeS>~sTNvCIOzc3g>%lXOd+(B7Tdm}=IO>qjTXUwI=9)9*GS{3bhq>lVxy#dm z=R|(bFz#B$J-TFec9?xKa@@p;FIf9YcfwN48KF&%QpVt)}M1IH%@L}*J&n{*iwBqCIw^-BX zV0+;Iv3K!x=J)Qm-~*8dwQtL}yEdkC_VLtUQ!DTCkgeVkJ%cy!zdS8e$~E(YY1mAy zj*0l_9z(7ate|&|{vUtI=mL$5@L*D?6`pt(`DM3JUjP5h^$>f3{)=}>-ott!4#t(G z`tgLPA1Gqn^O5zwTn`)!AN*ntJo$eYCTV{^Oyqk$5KK1w4`Fhr3zOO?m>g^YCdBjn zJurC~m_)7v@61Z@prMlb9UB_Hy~t0ukqcbB_Nu7o((`)woC!Ynt-dS??n+Vz&vcJ8jS3w8EaThxL0~QZO6;fUJe=H?lRFcK@+|yUIPrev~zdI#zn5*S4wq0x~oOg%ZpZ!7a`g|JyKVovDLZqOB!uzUbE2EH!~K)!`_!>Wcw|& zcPG#Fei~Q!ZdV5@{VcqZ`|bkg-OOm8?+WSD`sfLIeZq;oON_)7gCm^hgiqI975LLg z_Vurbz0Pl+dx?J-yna^tS6yZ{tsIa2+|W99_Epi)S2r%E_nj9?#0bv&ebqKh`{G(C)fa#>w35)45~h zyQ#i~xndqUuOYD^_8-cN(w?=W)}Ympi0)=TZIDxD=^^l?)ti@OM>nZ?5g!xX2Qzom zoo^QNjn}`wVVjDC^%8ir!bO8Z1;oMNOV>27<4aw0kwZj0wSc>$iBAv@abqY38DVq-GVXS7tXdP^ zS#G8r{dVGgQb%Q4fyueHwX`0YH*?UMlmPAdk!ws!J&^gWR}UE3AEp;)=gu4-a^ z&&;4LoSpb$$J-PgIt5q zGjMZX z=YUx={7Ny`v1!5jHu8|Z_qTj(o7Ws%N%=Ip@Z=i!>7+btM(E-WqsNjy&Ve)WXT!~V`4+OroS zmxs}_9s@@fW>_6-z}HDxYc?!=5PxFeY4Ul4-{{P%{l-QRV{8PY=u^)(>!GjF-~95s zhR*1l7T}iJWt+30`qcA>Xj_BPOZN0rc^?-U9kjzoczSs0;1}UvaFAG@3l6s z;roH?6JcPkHQIMRuu-_MpSgX_hFll+3+o)X?g93hz&>M`-@twxZIiRb%+JE=0lwMz z-6G9T&o?y#eB;iKzG(r*3p<$~u6^2yn4ctQoz~9hp&jgN=#Y)Qqz^3f*n2%#dbSe5 zbD(bz_1G31ejvIWYdn!PX6!3<1K3x>KL6f&=AkXhzH*q{rCUW@C-z&3XYrMJji}y^^d~X&H_Fg%74((unbZB`-EN75p)D{I4t7jJ-JueVEIoJBO zxj!$Ee=-fdc)+h*yTtF4kJo=BU-8TxyI2z$u~wGkNzV=;<7Utg;UWDQ)zhC(x_jyr-7~rzeEAgP%>7d08JFAm_Ai45 zO2##~d+C&(ePzp!I0tq!xO)Z~EO@jeOgFKL8jr>z9qnhKyV`f|rN16OX~wx;;~_Vl z_E2b=bUy{yDdneh3E!4<-}{`|i+{wsZhWxv(5nDT{|w^5edtqXj5ayP&HqKiL$M?0 zp5%XHX8NSw|1*+$|91@S{l7oK{0{-ibn22ChAn$I_5;!BRkG?jAxg&(_}<|tod;vCI)ViMaYqU%@gi0v_B zs%m1!>{UA%jAJbGdIG&=7+rN_JK>l>mBn7;wNpepVeNJHn5r;)f>+Poz{<~gbDPM? zU%9Gs!&u6MPpo{l8{hr{)}tSNO*i^x%YJ-A7IoC(cO)ID{ue0zz>Tf)a}I7x5xTp4 zugygBjrOR(YsiQ6bqsry^6pg9-vIsh_9%<}(4mL@&koZ1w|u>Om-6+rpldAsI^(y= z`_5h{x+0!zpuJ4IPw$>O*6gWiR@!UbJ{ui{4WAr?jsn})3l?@S`2GkVfo;EY>=coD z66RJqnd-tv`s+y)r?%bo3s`nwVKzB{jbFer`~r@^1uVlafakz-bVGAY zbIzVDbkJk0Yci(tbuR2 zF-&`)N$@Xw=V~qY_z1{0JqH-%qp!do=(9s#oE2;Mr;l%p;d!r3P z)OQ1QDc@u(^C{RhI&C!U`HW|R86Awv@2F1kJJ6y7hw)W6V@*p9<)Uv<-Fm;C`qh^B zQT=B;rmfn~cn+9{2a#<_zi`W+ot)~1PDGsnMnCqY-{%Vz)Bf?Rx*wkj9#?(_xO;Yq zGN+$Cc_aUeIY!>yR`(g|@XqtPIjWA=siQCkWIbY6c z;WY<(=C!ecHX_$;FaOzpefrr4UHnm9;{bxsBdg>R9;@agwxi2X=+qnY*pz2HQ$5 zu#14To{gZ6sWV1wTzC7GT{`b*r#-a&{WmwIh{sp?+bO@0IzPbX$KJYBvY~v*=IyH5 zK)&=PrPw3|v+(RqDdZ}RE3mBD^lzrt#IM1t$Th8X_Di1qZ>_U6e{$AYB5^p8_?s>} zwtGGz_*U)Zdp)H2;@-pGdx_&}X8-Z$c(L#(s&prZ3g$muKRu#(|()T)!{*1x3?q?T@1Kzi{dNwa$FG^rk<3|NYEZ=>qmy8Oh?=s!eX>wu+)lHrX}e z&B9il$+@rx89DcSavRXsfIqKkF88J_yX;wiEb^n~>0!>HO^XjH?;7841Mj-x(UUCU z8pls|2<4B6%e3mvyAs>+VMwyNR9EN1U$O7>-GeLqTHokwwbqAQ+m9n#er)_A@C|3* z*V;{Hts;YO3rsTh`Sxbulxruo&z`Ym}}`CzWrrG5R$Eyx+q!_$Z-(pgv;_;QLTO7-de4>uf{ zNsL3l+IE7qqW|zjJ@Mwr@I;+Ake}A@MCwnH`Q~0^_e%H|@{U(k8vZ5Gxb&>;XQo#|1(eN|K`vt@Uc<)t9#%>WFyRQZO38oVhK9t!p$BZSkjaCU%LD{ zct4$EPzN8#I^HgsTj$k;;r-;pqBCnM`7eE#&aDw%o4DRkZ0J4O*IL)wnZkKi^T;hn z-l>pZdmFeu4_m{^EGHkz%1q*r@gddzqns%9@D*Mk!EL9H+xz!%;tJn&(yztPhM(&e z`YC>F3w_kO-9rC_CtK*7{3!&JxRabsLj7LOhMxNOc=h+5eGnZp_*v@L`Q$r3cG@sH zIcz3%(4R;e=<&54y&LV~qm!qH^HV<8m!I&?Al14VnCEx&DV~pq6eP;*&m*gEi_;93(|b8cjs%pJ74SF`C4!0Yji{F zC+wBz?knWeTb^xfDiax7ifc!Bi}$rXHWf#AJKBA>-z)3lzSey{WyzuT_yD|bW3d>ji;Qlozs_|W46T;Va@vpWnsJw4yPh+cGkHhjx0r~Jb`pE`7-Yk-$j!<4iw?%N zZyWt@U)ZE{;-^>b7}ytE)mZhHZ@y#Vb^p{+7I zX210R$Rhg#;&LItROaz)i?LU?{tITH-V!!f~z-xv)6;W3pjV=I?i1k zZ1DXb8B;XfF>)n5u-SXqYx7)rv638#vcY*eRs5rjyf`H@uRc@q;xoZ|bWwXt?YJsz zk$W?c7n6_|v7c_&K3+O;UcLM)wCEA`CADl#@`2ifA0OnPf%94 zAbsIi7{BO?=+q7TFGaVws~I;NuT)AhE0R48Siudp*_6nl@-G_uCo)W zUK%#E>JIYizq%{0eivmYPTaMDScn;ie5*FRLb-_(U)k^uce5f ztL`Kp{*j0C>JL+HA!Q~`+`Zv2-+e^6853XK&`r6Ie4!1~t%Ry!4-MP+ksVXDb=Zii zmsn5h>|s@pz~64Qt*Uy)&RMej;;Ur8uz0W7u@dp?%mY|l6-l#2%d|(@$Gf-JfvZ-6 zITNzT2A18cZQ>bh@F51g@PQxryFV}pKlxstcRyvNJ2vxeCx#{iuS|P#r)+l1zyZ;; zo8S$!ex!4lJHnc!>lAbkOVH!46y6|TyqIcbdAfonsn+%-=pEhzkDkJxwG5nDX~(ti zV!ow&C|jJ@Rft|*&zgyqRa-r}Y+{#7DVxK0lkq7n;oE%nh&kjBY6H*3n`a>N$j8(# zKdC&2SN9Xs5T>5gSYnBhSxPBq^ree!bfzjDIb1d}Lo)uiF=*bKv|e(=X||OK-)Gk2%b@ z1AEWdKfc#@f}i@h5Zimnt@ygK{*=#Hxs5SW^ypq1kozOsg@$cAGzpoAwecI-mylH% z_YBtG0_jpbSb{U#OOU%VlC14v_^>hP!r#dcb__e(y`XLQm0fE(y0^vMdjOkj z^5%m-`cCt!H5$iRKJgPOigKCbQgSyI%9jDX&RoWqY0v6Rb^W|GCw{+^BZ%`)7rcW$ zSF}-dvyOaA1&TBG&Qe^?TvLA>^_Mb!_|8UPXmnNe#7}dEtntmBJIuiY=6qWNI3zul z`Y9NR9xY*hGr=J}lPzQ<;|^ed_sZ8Jg9wfbp!Y2)^O{MiFgT##YupKO?lo>?^hG^%nN9 z&GJ7Y21NF-KWwmTqMuJ}FE4tX=gs1~{=?Vv{VJZn>OH?Iw&!{NN5q|a&&l`d)xWr_ zyyyj$)^u!Y?pD3>ldVQKP=H7Wj;4<6JRj??)zL9%~L z?i+1BV$f{=-kmWPzOu#!Y0ml(y-3zK6K_V$lSxwJRL`g5txZa}b@b#J@vyx{TNx(E#Yn(lYU>z-%7yC}Z8S2hKxp zTb7A!P`tE97mdvidNB_=Bbd%)?AHTNFmy8W$ld8yygU(o`^Lxj;G$-Z|r zGRNJF;RnD+e24bHHS~E3a8*Cb?6|#S?fCX8>|dEt@Ldz3Us~&yXV2)TU)nSPZ2iEg z+=Z?D$yr}T;<>fXgg?4300!C*TwZTqq^u-fZzsB=Gv!PbKk(i~@J`qMhP~d2iSx(y z(j)c?WFg02PTv|H5M8uC*6KPNeR}OB=nJ{{JzaDQ{OuaX?%6bD3z03wqfeJH2a)vY zw<=E=uY3t~J-WP~^3k>w!6^Ef*M>KajOnrHiFYEK!4qh#>@A+(Kn43nov+t#pdi9; zfIa4j$Ky&4%@b{BZ;A99*bKZhC!W7RC3|gJ#J%T_kc$4a6&r;2UF`sLas7{?{SO^E z@|hFBhJET6z^4R#lV3WwBx~s^a1>oowb3_xr37Do_Vx&URHfDoGDqP#fqwq-(npED zcremjv)>tA%3@cSve?z7EOvD%i+glmqZ?kLPd$A>j@*O)gx0rbUtP+Yg8z|y6@5sg zUev*@@I|qG{2(*%H;wi&wed^tq0^c-zt7S7%CqBy@=lL#`28^lC;HW2`oB}ZavppO*gJR^d^ELJAL^`uh+F_39=-~# z8yF9JeI%~{7b9bY>*hG|!CQy}%`cf2dXoF`=z2A8UX1XmJNw&e0^eCx6v-oW4+-w? zej&l+&F;%*kb6Nf;lweD{!~hbg>TmJNmdB|#84h_9D&jJT(K7tA63;rJo{wg*%OCZ zp`A-+W($v=MlWm;7w+--@&W$>I7U4o^cSJ@)vJm+N0HYp!&q z<3-MpuH-(+4$M=T#JsGkwfM@e#V$~c9HTvV3VT}|_}Tu_x}v==J$=_yyL{9Kd`k|y zhMz&dURiN|=*WsG8%O?a<-vr%Eq*K^W@P-xFAg2If6vnFyph((XZECLf0Sv>@1AeX zXkl&5u;X+l_5P2135o^pKp(e2vHEQXt^48_tM>NNrPh5noNt9vOJ|vJ-o-fK-?Lg` zlIAI1*;_*zzj7R)%j>iM9b&A&Jp|mz8{Vs1J$0vJD-s;ytW^gZYX0?T+_}w1+Zc;@ zi1zNs4$OO{{%HK9T}N|6cE>HO=iJRBoGbF6?xzM`+~A*Kjg)V8n{Q|XXKyWdiv4FU zbvAG<JO!LuPY}B`5Gh4TQ zUCIZ!KPtNVTmN#G&hDPZv#skNNm2b(6_2F6e`RIS`*!VJAH4c#(Xi3A=9>>_L*IOl z>&2%pKREsL6$eufOz&vqKH&$|Dc!#dbi6X7nmUHIKV9)Xu5s<973)$~(XaQXqPNn% z^X2no*`D@qK}CMz46!RD&Sonz*lLt{FHigFC5~(`nQa_ z)&60pjXtpw=eq6x;lI)TaM~ZpP8(P?z}la4=CyA(7f(nfW>oU3|8E~1h_S!%leDzO z6D;;ME3J6KKc{KGu}-5ut^bkyL?qq5%g@VbQvpGJsK!q^=KH|0(@}0cz@}6 zi1{lbUvf?{a}!x_khQFOZ$(csP`#H@Z(>Bfm2SPn9htKQRp*1MlRB0Boh3w^OgL2TkoGjjnCsjG*cvBZ~9vz2}$5dA3qzCfKrJLtg&P>Y9qFmkij}*1M z{PbO6&OTYsGttr1(yZ(g*amBhtk4KzDy5?wbCneu1OHqi*>)P|o?6!Fu|X?m?B&3Z za{dA<=a9yV{2`n8lxeXw?~t>+QsR7r%LhH5j;@={1QA-W7&%SJdxsW z=F#VJ%1q>p_(R0sTp2Znnw~Mt%&vLJp|zt}Bh9SeJmr>(?Ooqjx$7&=U0+WBS#QvX zE8KTJx9lRx{t@f>GM>A%yD?IB5!?;!p3|3hdv<;YzO%EB)3@&DtflWB>s}!IkS$ia zn&bcK*f=b5lQz13a!DTCb>1v+{{?s@-HvG2XNftw1)j#+b54Hf{C}Bef=TqahMZws z>CBbn&NGf}jFtHCl-L?$DXQYa-nRFmA#JKx8%~%n(%lK<=r_CJQ4oZ?M~~HQ|A$P{FPBTb^eX#@=a$? z?lZ4ir=EQ+(vO|JXrDFS>_z*e8`}L~dC`I&7~Rl5+1}Vw%yZ-a&+|)x(cd%1t^Q zz6Dt1H=ZAQl56BS?ce78UEs#?$$e#dPj2$&dl5K@-L+Bq(F6FoY>FY?pB$1lx(ngA zCzIrTsB>gSHzd;UaH_L$+n;wqsGe))^%s~r(uo7o_p4ZcUL98)>mPfnIJ)k? zc0V2&M`z}@D=9@mTO-_HNMz!IMcL4dOu`twM{;;8k@*nVML4_;xR=6^6g)8$` zxc&p;DT?X8#`-c>_M)uH2w6J=_#$gJ7XGw8vMDXOw|{H%#1f zh8tT_iR~bR*b+ZFD*spU;UkVDP;46;XaE~gCN`n~Hlhe!&pzwH$vyP!@ZCZ!KRp-Q1a<-GPNg$Vhpwwk zV2x~A=%&}$#y=fAFFFh!MEe#`Nbn_^HeA2Zb+l>fb?q0T|H7YFKXUd~{U3RR|EnFF zwppL=PYD%MKZo)@E*)#Jr`yeQ^Siz^HI)CbV@ol8`@rqn2hMqYo-Kqum3^W&=E3uQ z_IzPYKl|E8_IdV^ZTP39eK!4LqwFK+vMyAgSN@+U|KHI+D&NHb`bX17^jHQ@LC;`( zM+Wj4u^K)GXZ!Bg=m~@i$O$&MEgENFaLDK6q`|)JUvo`U3Au)i?QQ>+U4_HYk=Nt5 zZR4xmwenyg`Om+~oG7q|K5$EyUr?uhbX^3uP`lq$A+R0H7 z@6M_5D@K3uH0#{72`+0KS|ee;g+YVGomfwOcLiKHW9ULPEgY)NxdvrF~Liflez$$SOYCrUJ=tTXn+Sx z^*a!`_8GGctOsg4&&?4#Q2S|C>Kp&J@OeFS|FhupH~G$t?<{NPO!)lZr?uhY^NsqB zm8&a=)_cPh^B+J~N>F^twed4DQ8;;VR&aL!#3E34QZB1r8 zk1z)K&N}RC${%o=yx7VefbOhM?f~*u61N_UZ^|I_J@6q93?VMfHo0?T$0-?-FzW{3 z^ZKKleFr*F~aZSMkJ zHOR*}m450uuWGZ>J?FI^du78!iyU`W$6VGx2DU!g_7s1PkMe%Sm)8w&&TGaQV7M6= zme7xS@=asUTw5|2d+4CxQF|n7ogCp>Pg@W99NZXLXy&qty%X40`(MAJt7H_mI_AG5 z#cHaQuO&P~$u*YvlcvCmnCmhImBhijj4Fw-n@T>PM~s8D?e5)yP%$uE^hL#E=Dwl+ z!>gOMJ7$et!@ji7e<&to4GL~;9b#>@0?n7T#+P4a_Kb1F@B5s+LVU*BRqpq@e4pw2 zhxmROK3R4DoL7%7>!~30(ju4QPT~zk%lyD0DUF!NQ+9m;Jg_BM-fx|jJyfue>>sY+ z>{sye-fZ{)aP$!6(z}Pu&hQ5x%{;Q=QT9-B7E~B{M?QO%z?U`f%p%~%Ik-)A@K?lu zHMM{zmB1meD0n~o)BM09$2WdGb6L*wNz{+czdap3N$u@muEcjouY>74FQ!Z}ux&vP zuUs*j(?GEN(agW>JlfX%_EB$cqw|+4U#r$lIr4#X}Pv;AGm9`(msU87j5DzoN)VB;-8JIA`a8*3Klgl^{9xZ3if7IJKk z!LGZ9F=uj)^&Zw+8|CAc2J6?-*RSFy5s%$peiE0mw%a-DV=6NAO4fUoUA18e&xrf4 z&k#SxddHutYY+Q%nD00jZi&sAUefJpZA?Pd0E z-vz~P!>erHgMXUJ?BTqwg71IH#J@`CyBYeOp?>4*6o;>q(_UPAIe8Q1Gf;;NrFs;@ zV#;@RZ_Btam?_$yMqa|h#KJXuy!zFSAHL?q>tEi9?gfI0Q}4L zt@(!b9HJk(PpV)}@v+?hTW5^H&NsL=zCq_tIk?r|cj?rfzc+D3 ztfxlTcgb02_%1yiinh^1pUM>{^$+H3np*m4>#-j=d)XED_1dHE!Y5uhum|^8 zbnDe+P5pK2if;lxP5yfeKOOlR`tdmk<6kKs3pY0z#}e=#Ujz2uZ4U1B+MwsWi7xS#D+gWOXWLap1topdxK}} zku%v_oo|PZ3#HSydv;r)Hgb_MuUYErJ%^q9Hs(1DUzkZAw_NhLG0zK_)9f|)#5c1i z2BDE7Mv|X9QXj@1jy|lq@a6vcF!@8&kvBerHhTK-&2dK7Olp&zy;lbi8Lt#ZZzhF5Wqo1NG~8xWBH`4-U>{4wd61&JM(-+7GeUuicPo2cLy*zt>FLEZ}`L zH|yMDN8cBNJ?x(Mdu3Lg8^oiW4%44l_q_?3dZu#j_b&}`6gc=ehhlVy0=R@z4HP$!yly=UltkCI_+hsmQg@K26L@_0~fUER z^0q;i^o1tpN-cOXrDMgx!qT$N+o)@8>6A`=J5Tq6f=!$3ab>sTbFj%CUslYO;G0CX z`6hk$-*;~(2W?c>555xr^+0*?ovi!5=qHon%k@#Xg5BKzys}ot)wm0~7kFdJlWeqv&U%BMRG(7P^jEU__^*i~cBHumyg3r9$$r*h%=g5Z{ zQz>{l+4Ymxe!ux5_VC%gd-a}K&VFBdk=gIb(GuY&&sn|E`+eFpYe5C$xrcmoS@=HI zU38Vnan!=t{7ID?>VZokbm<*(A?A}yPq`HA^gYJ@bIu3dk7vk_Kk0IFW|;cEnrF4l zmq+7dBl-=`bpIgNX5O(L8`JTz(zD&Xf6je>xBDL1r!mrY{4<`oM*46HPl?Xy*y{{mt(C=yqy&K8to*QhM9DmS@rJtaa-taO>Hn@vL)j>oD!C za^DBt_tEVv=XvBK@{j{T=U$;kymaopNwnXH|<1M33Pl->MBi`DNjm$q9IE zlb`k?@$Q)c+IpX7>SqI23tOaV>pz^fgmzR$(W z|9~;Sagk$J(6_tM)4y%LcgpHs{N_e*q?NrT2v47ay|ykU^%D81vKGv~_A&8a@Zd9N z?&&r%j9?uDPxFt|bvg0aX3u=l!8g6zol;)Z+|!P8uXZYECtB}y@*8uANzJeK^k4Aj z`&|BfpU0nPe$VK+s$IFf+KVar{UiP5LC+_^%7*8O?aTAjHAnG0@P{66_7r?*JA5ea z66TEZSK!lr7Qv?lfyFe#r`_Ip2mI8FrPB#MW zSG<_@*4>@!(Y<**5o=+Ae6M~vERt`V+1XDX^!3jN<;9)jw>i1^p%n-3WQ^!#r9VAT zKL=TEh}Grgzs5z!jLqXg#Xs#t=by&d+L`0q&~LrZ_hsnI=G~X!@}GgSM-2ZNS60q- zpmFVDT#`GbcZbKthTiLgd#3ybk>~sbm@8r?n&9)ciD#aS56^V)Hvo?>el-oa=k&~- zW5*!JxS6{+zA1xWewz7vpZP=dH)H&W`Fqu!Kj}I&e-}|tS?L{}cQOa8~2k>s}lYPd88*UYcn!;So%cI=LUU|^0^jpM#&nT&%_?6 zn5+uL9ian{jLAyl*&E=!SGMG2_jdVdMCv`DUH$futaXl0&Oo`clL{(}j^m5p>cbD4 zb$B{mYnPbu`0^(I`AHikE4&eNaj0c?psTIxCkING{OV}^-3O1ZN&eH(z>2vWC!fCZ zvAL(`R_*yspliv-?;LpX<9|I`xbF`~m)sIQ>R(Z~G46EHV+E%Rs#=~6bRGQt+^#*p z4|L5WuD){2ye8?dD#ye(z4g$Xu4ee%&FI)SUI;J3{n%Y;Rc}7z>*|h4Zf}J~c<&!` z-zW3_E#9a4#9-UkD($Uhte8QOLZVUjE*^JrLcj(ek1~v?I zWJZ7S6n&A8wRA($XDmUVI2jlh{&-$fJU)+0hP{|l2wjP{UbA8J@UbWkJgcULVg4`zeVT9UNbS)y0+an ztii{9EWh+`Z@I?ecMHF7^DCq5B5dN;w>Mu?{O#In%K2H(KXXmIb-{#9#BbEn=Q8HX zPoLChVnY^W(ud5Bi$iP6RuyHGPVQvCXu3&!{;T=u46K=#=8LCva!-1&yA{~!SqAWI z1IOM2$1*dMHoga5Edj3@z^k?3RVH}j;ngAX+vcKEksY{Ldd;iGgkD@1QeXzl&p6 z6)oWTQ)%+^{dBz~@alr5HRhj(UK{?##_v0hy;J{p0K+uKptbrl{#z0M;fIP!A1Xdr zh`y}Q9v-^kAJ&;v>PUY|}7fvaDU-14_N@&sWRYhO&=J3lYJ#)Ba=&GW3*PcE8UijkQ6&LcK zAIt<^OJm8QEjzqy(yuTEqjy>B*l({p$C@eMQo&3*soBiOi7ogr^IazW_3Vk*#AJI7 zWiTiBu#5~d-Z1b?Zo?1cXZcNQhPR|-?DvoB;=FU&DC>R={xkj*ztxoi45jZ+^Er8y zKV-bx%V?`HoD$!p@|uS#aGf^x6VqCqX(xmx)0e_sQ$zC}4tADhmN!i~y{RSBjxl-Z zL?@u%M=F1Ch4FP^zPBZB4%S=jRo^u8oovp^_z~|vPy66mV=}mPGi!ck&s-)pY+~-h z_!s!ybNo`cKSn$~=QeD+nYrA^TxPg)SspQ$xxgV8+-#ul3+cP~N9@Yge)_F@jYaZK zJ38X2jCBb)RpqZH8rkr(RZZkp;*60v;{&t?eCq}dEzF5*d90OfIn2f9`DrZ(SBB!- z*7H6dJZ8Qc4g~A#*{{|xcKGg$(2AlhuuI_Dkn?$=ucQT*h&2% z;Lm)U{drM#XfC{2DQD6a>KS+%v~T%Iy1n=(Y5w8~nem+m(usAmk{3+~Soi-#{9-P1 zPzF90TD3*F6~T_#6>B$`x;Bp@myDB-cpkX9(jMDBk?&4F;P`5PeBPkrXhOfMfd`ldc- zX0jjT{DRz@!|49xkrCfUbI=d^XJbE#m{oq!KBx^A72Je*G0q?)PtMQ_yfOD8>$0c&(P!DI#;?e z-9N$M1I_~PV_8nDz}ax+&(P-MzF1<=o!qr2@M8`z<~`V&;giZX!$V4DF6X>OV*lpj z`!oN#5!TEb6Req^;}@`+b22!aB)8OFdNuQ(d%L}~T-T-c(#`S2jA4%`Zj+Fg@=V$9@rH?)Ump{ejEq_Vp%VU zlkZOR5Vwn5H_xm|*+YBk53$2#8T$)Nf5NnzTThNvWO~6g>u9&hllPzJI^$3u-{!yi zSc_fH4?M+d%m%)jh&9r=8G^sxu1Tpo(w$}T-1tUV^p|?&Yf>5Sz*@cqYO9t#R6IdA zX7pt7nyu9FX=^{s{QEiIS?jiqeu}4=d~Ps%q1#3)`EbCIrP`lm9~JHH);;Z~sV&Bt zOw5Y#Nb}^J^E{?Oxmf?i9`tASlgl}is+RnJmB{NYUENv2U2xXOxEdEYR#b`HuG}Qu zbL7K12>Lk~`icMQ1Ag+5z$0iqPjl#I-Rkk3VPd@f#CXT6KSQi0eA7dRpX9v7(TZy>AUftA*qwf$fbm_)IKKo< zz5;)E$A|G+aH4yT4;Tb^7sog8z+fma@b%=$jD`icux%yq1HL8(x0bk2qko9F4}i0o zyI3>e#l&;wEn1d32YTKWE*BpTj*n_V-bq&+>+{30GbA-60-rRV8Cv%b z@~22L@T?_2k$HBT^DK_OY&UY5i*k@O@ zSHcb9gkpt##AduV9vBlZ{0>+A9SrW^{~D70CXGG`FOW^QzsY;Qg}&`8*3xq3EUf(v z`*|gDsOo?0RQGoEr~IKY8wZ+a`6ixz;40>D9&=g9JkMpGoqVZdkjZtVHXiyjQt$OFVAUL$OU{M>IU6E#I;2+H9WPKWwRS|uySiF&?LKH=yx+;+kZJ|m zTYd2jyP<#Y(ihbgM_q-~6-Qn1e4y5b0fx38u34n_C!%VIU(Z8pE$6DoDhZNglH!x#9nejw38De)rxN~W4_APSEam9 zPKc|3Q>rzzeJanCCqiRblD;k_9=Nsx*Y}|pL!lS?=6%}HH;-@~e(LgrBTij$uzUaX zj-PY?(faSFyhBcituww)9psfre}rqGeQtVHiU(`;xt_kOzyHEDj=m~?MLd1|Z|AR| zouTygefs*}&R>!4=C5Gf74&N%xU!ph^zeb$nfO%OHt{vmN$5;vj2+d5KTZ!V1P5+G zXLQ^1KRGb;;irziKmVisJp zz1AjY=b&KDh=R=QYfA6PzL+|P7hIg38L;MG16&V-Gw(P6#3%fH-RgMp<(gt?^CUzdq4P#{p_^Qy?gXSd|K|b%X5yjS@RPnkX!3*-_nWc zSD$1r#7Bl)mcVgdm#CztpK`{0cudDq~zIP*dZuZAD@VVRzmM!d!VR%TsLsoHkP|cajdiFEs#>ky* zlYVu3^!Xji3zLAc)yo^%J%pT4{hA|CM#mUfytA z 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
1-mER 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�cl`6adu8Z7d~ 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
jMVoMvjUyV2q?3ML1{)2HI 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
<}u(3y~*;KhcRIAdnay@R_y_ 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

rqI+JDK$6Ln0$XoG?{j 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 + + + + + + +MOABAM API 문서 + + + + + + +

+
+
+

1. 개요

+
+
+

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

+
+
+

1.1. API 서버 경로

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

환경

DNS

비고

개발(dev)

dev-api.moabam.com

운영(prod)

api.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..e28c38d8 --- /dev/null +++ b/src/main/resources/static/docs/notification.html @@ -0,0 +1,494 @@ + + + + + + + +알림(Notification) + + + + + +
+
+

알림(Notification)

+
+
+
+
콕 찌르기 알림 기능을 제공합니다.
+
+
+
+

콕 찌르기 알림

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

요청

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

응답

+
+
+
HTTP/1.1 409 Conflict
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 64
+
+{
+  "message" : "이미 콕 알림을 보낸 대상입니다."
+}
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index e21a1750..d733cd5b 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -43,8 +43,7 @@ void setUp() { void notificationService_sendKnockNotification() { // Given given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); - given(notificationRepository.existsKnockByMemberId(any(Long.class), any(Long.class), any(Long.class))) - .willReturn(false); + given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); // When @@ -52,32 +51,31 @@ void notificationService_sendKnockNotification() { // Then verify(firebaseMessaging).sendAsync(any(Message.class)); - verify(notificationRepository).saveKnockNotification(any(Long.class), any(Long.class), any(Long.class)); + verify(notificationRepository).saveKnockNotification(any(String.class)); } @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않을 때, - NotFoundException") @Test void notificationService_sendKnockNotification_NotFoundException() { // Given + given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(false); // When & Then assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) .isInstanceOf(NotFoundException.class) - .hasMessage(ErrorMessage.FCM_TOKEN_NOT_FOUND.getMessage()); + .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); } @DisplayName("콕 찌를 상대가 이미 찌른 상대일 때, - ConflictException") @Test void notificationService_sendKnockNotification_ConflictException() { // Given - given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); - given(notificationRepository.existsKnockByMemberId(any(Long.class), any(Long.class), any(Long.class))) - .willReturn(true); + given(notificationRepository.existsByKey(any(String.class))).willReturn(true); // When & Then assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) .isInstanceOf(ConflictException.class) - .hasMessage(ErrorMessage.KNOCK_CONFLICT.getMessage()); + .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } } diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java index c54b93f5..fc011ffa 100644 --- a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -31,7 +31,7 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java index 87c1c5c7..6198be33 100644 --- a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java @@ -45,7 +45,7 @@ void notificationRepository_save_NullPointerException() { @Test void notificationRepository_saveKnockNotification() { // When - notificationRepository.saveKnockNotification(1L, 2L, 1L); + notificationRepository.saveKnockNotification("knockKey"); // Then verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); @@ -55,7 +55,7 @@ void notificationRepository_saveKnockNotification() { @Test void notificationRepository_saveKnockNotification_NullPointerException() { // When & Then - assertThatThrownBy(() -> notificationRepository.saveKnockNotification(null, 2L, 1L)) + assertThatThrownBy(() -> notificationRepository.saveKnockNotification(null)) .isInstanceOf(NullPointerException.class); } @@ -117,7 +117,7 @@ void notificationRepository_existsFcmTokenByMemberId_NullPointerException() { @Test void notificationRepository_existsKnockByMemberId() { // When - notificationRepository.existsKnockByMemberId(1L, 2L, 1L); + notificationRepository.existsByKey("knock key"); // Then verify(stringRedisRepository).hasKey(any(String.class)); @@ -127,7 +127,7 @@ void notificationRepository_existsKnockByMemberId() { @Test void notificationRepository_existsKnockByMemberId_NullPointerException() { // When & Then - assertThatThrownBy(() -> notificationRepository.existsKnockByMemberId(null, 2L, 1L)) + assertThatThrownBy(() -> notificationRepository.existsByKey(null)) .isInstanceOf(NullPointerException.class); } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index faae1803..40a5f18d 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -39,7 +39,7 @@ import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.fixture.AuthorizationResponseFixture; -import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.handler.RestTemplateResponseHandler; 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..5259d56c --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -0,0 +1,121 @@ +package com.moabam.api.presentation; + +import static com.moabam.global.common.constant.FcmConstant.*; +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.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.boot.test.mock.mockito.MockBean; +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.entity.Member; +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.repository.MemberRepository; +import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.domain.repository.RoomRepository; +import com.moabam.fixture.MemberFixture; +import com.moabam.fixture.RoomFixture; +import com.moabam.global.common.repository.StringRedisRepository; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class NotificationControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private RoomRepository roomRepository; + + @Autowired + private NotificationRepository notificationRepository; + + @Autowired + private StringRedisRepository stringRedisRepository; + + @MockBean + private FirebaseMessaging firebaseMessaging; + + private Member target; + private Room room; + private String knockKey; + + @BeforeEach + void setUp() { + target = memberRepository.save(MemberFixture.member("target123", "targetName")); + room = roomRepository.save(RoomFixture.room()); + knockKey = String.format(KNOCK_KEY, room.getId(), 1, target.getId()); + + willReturn(null) + .given(firebaseMessaging) + .sendAsync(any(Message.class)); + } + + @AfterEach + void setDown() { + notificationRepository.deleteFcmTokenByMemberId(target.getId()); + stringRedisRepository.delete(knockKey); + } + + @DisplayName("GET - 성공적으로 상대를 찔렀을 때, - Void") + @Test + void notificationController_sendKnockNotification() throws Exception { + // Given + notificationRepository.saveFcmToken(target.getId(), "FCM_TOKEN"); + + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @DisplayName("GET - 찌른 상대가 접속 중이 아닐 때, - NotFoundException") + @Test + void notificationController_sendKnockNotification_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isNotFound()); + } + + @DisplayName("GET - 이미 찌른 대상일 때, - ConflictException") + @Test + void notificationController_sendKnockNotification_ConflictException() throws Exception { + // Given + notificationRepository.saveFcmToken(target.getId(), "FCM_TOKEN"); + notificationRepository.saveKnockNotification(knockKey); + + // When & Then + mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) + .andDo(print()) + .andDo(document("notifications", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isConflict()); + } +} diff --git a/src/test/java/com/moabam/fixture/MemberFixture.java b/src/test/java/com/moabam/fixture/MemberFixture.java index 02c5d84a..d8f424c1 100644 --- a/src/test/java/com/moabam/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/fixture/MemberFixture.java @@ -16,4 +16,13 @@ public static Member member() { .bug(BugFixture.bug()) .build(); } + + public static Member member(String socialId, String nickname) { + return Member.builder() + .socialId(socialId) + .nickname(nickname) + .profileImage(PROFILE_IMAGE) + .bug(BugFixture.bug()) + .build(); + } } diff --git a/src/test/java/com/moabam/fixture/RoomFixture.java b/src/test/java/com/moabam/fixture/RoomFixture.java new file mode 100644 index 00000000..7a7af4a3 --- /dev/null +++ b/src/test/java/com/moabam/fixture/RoomFixture.java @@ -0,0 +1,16 @@ +package com.moabam.fixture; + +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.enums.RoomType; + +public class RoomFixture { + + public static Room room() { + return Room.builder() + .title("testTitle") + .roomType(RoomType.MORNING) + .certifyTime(10) + .maxUserCount(8) + .build(); + } +} diff --git a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java index b2f424fa..0deb6cea 100644 --- a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java @@ -1,74 +1,70 @@ package com.moabam.global.common.repository; -import static org.mockito.BDDMockito.*; +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.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.Spy; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.data.redis.core.ValueOperations; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; -@ExtendWith(MockitoExtension.class) +import com.moabam.global.config.EmbeddedRedisConfig; +import com.moabam.global.config.RedisConfig; + +@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, StringRedisRepository.class}) class StringRedisRepositoryTest { - @InjectMocks + @Autowired private StringRedisRepository stringRedisRepository; - @Mock - private StringRedisTemplate stringRedisTemplate; + String key = "key"; + String value = "value"; + Duration duration = Duration.ofMillis(5000); + + @BeforeEach + void setUp() { + stringRedisRepository.save(key, value, duration); + } - @Spy - private ValueOperations valueOperations; + @AfterEach + void setDown() { + stringRedisRepository.delete(key); + } @DisplayName("레디스에 문자열 데이터가 성공적으로 저장될 때, - Void") @Test void string_redis_repository_save() { - // Given - given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); - - // When - stringRedisRepository.save("key1", "value", Duration.ofHours(1)); - // Then - verify(stringRedisTemplate.opsForValue()).set(any(String.class), any(String.class), any(Duration.class)); + assertThat(stringRedisRepository.get(key)).isEqualTo(value); } @DisplayName("레디스의 특정 데이터가 성공적으로 삭제될 때, - Void") @Test void string_redis_repository_delete() { // When - stringRedisRepository.delete("key2"); + stringRedisRepository.delete(key); // Then - verify(stringRedisTemplate).delete(any(String.class)); + assertThat(stringRedisRepository.hasKey(key)).isFalse(); } @DisplayName("레디스의 특정 데이터가 성공적으로 조회될 때, - String(Value)") @Test void string_redis_repository_get() { - // Given - given(stringRedisTemplate.opsForValue()).willReturn(valueOperations); - // When - stringRedisRepository.get("key3"); + String actual = stringRedisRepository.get(key); // Then - verify(stringRedisTemplate.opsForValue()).get(any(String.class)); + assertThat(actual).isEqualTo(stringRedisRepository.get(key)); } @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크할 때, - Boolean") @Test void string_redis_repository_hasKey() { - // When - stringRedisRepository.hasKey("not found key"); - - // Then - verify(stringRedisTemplate).hasKey(any(String.class)); + // When & Then + assertThat(stringRedisRepository.hasKey("not found key")).isFalse(); } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 00bf4697..4f6c4f27 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -3,6 +3,11 @@ logging: org.hibernate.SQL: debug spring: + + # Profile + profiles: + active: test + jpa: properties: hibernate: From 40e5c1a4930cee8c30bc31edecfa2cfe09c63aac Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Tue, 7 Nov 2023 00:10:26 +0900 Subject: [PATCH 020/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Mapper 클래스 선언 통일 * refactor: service, mapper 수정 * fix: Room nullable로 수정 * chore: highlight sql 설정 추가 * feat: 방과 각 방에서 사용자의 인증 여부 저장을 위한 Entity 추가 * feat: 방 상세 정보 조회에 필요한 DTO, Mapper 추가 * feat: 방장 정보 부르는 querydsl * feat: 인증에 대한 정보 Mapper, querydsl 추가 * feat: Participant, Routine 수정, Search querydsl 작성 * feat: 방 상세 정보 조회 service, controller * test: 방 상세 정보 조회 통합 테스트 작성 * refactor: 코드 리뷰 반영 * refactor: checkstyle 수정 --- .../moabam/api/application/MemberService.java | 13 ++ .../moabam/api/application/RoomService.java | 157 ++++++++++++++++-- .../entity/DailyMemberCertification.java | 49 ++++++ .../domain/entity/DailyRoomCertification.java | 41 +++++ .../moabam/api/domain/entity/Participant.java | 6 +- .../com/moabam/api/domain/entity/Routine.java | 2 +- .../repository/CertificationsMapper.java | 50 ++++++ .../CertificationsSearchRepository.java | 68 ++++++++ .../DailyMemberCertificationRepository.java | 9 + .../DailyRoomCertificationRepository.java | 9 + .../repository/MemberSearchRepository.java | 33 ++++ .../ParticipantSearchRepository.java | 20 ++- .../repository/RoutineSearchRepository.java | 28 ++++ .../api/dto/CertificationImageResponse.java | 11 ++ .../moabam/api/dto/NotificationMapper.java | 2 +- .../java/com/moabam/api/dto/OAuthMapper.java | 2 +- .../moabam/api/dto/RoomDetailsResponse.java | 26 +++ .../java/com/moabam/api/dto/RoomMapper.java | 30 +++- .../com/moabam/api/dto/RoutineMapper.java | 31 ++++ .../com/moabam/api/dto/RoutineResponse.java | 11 ++ .../api/dto/TodayCertificateRankResponse.java | 19 +++ .../api/presentation/RoomController.java | 8 + .../global/error/model/ErrorMessage.java | 1 + .../api/presentation/RoomControllerTest.java | 123 +++++++++++++- src/test/resources/application.yml | 2 +- 25 files changed, 719 insertions(+), 32 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java create mode 100644 src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java create mode 100644 src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java create mode 100644 src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/CertificationImageResponse.java create mode 100644 src/main/java/com/moabam/api/dto/RoomDetailsResponse.java create mode 100644 src/main/java/com/moabam/api/dto/RoutineMapper.java create mode 100644 src/main/java/com/moabam/api/dto/RoutineResponse.java create mode 100644 src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/MemberService.java index 112fcd7e..f2d32c7d 100644 --- a/src/main/java/com/moabam/api/application/MemberService.java +++ b/src/main/java/com/moabam/api/application/MemberService.java @@ -2,11 +2,14 @@ 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.domain.entity.Member; import com.moabam.api.domain.repository.MemberRepository; +import com.moabam.api.domain.repository.MemberSearchRepository; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -17,9 +20,19 @@ public class MemberService { private final MemberRepository memberRepository; + private final MemberSearchRepository memberSearchRepository; public Member getById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } + + public Member getManager(Long roomId) { + return memberSearchRepository.findManager(roomId) + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + } + + public List getRoomMembers(List memberIds) { + return memberRepository.findAllById(memberIds); + } } diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java index 5480ef16..5425f829 100644 --- a/src/main/java/com/moabam/api/application/RoomService.java +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -3,25 +3,40 @@ import static com.moabam.api.domain.entity.enums.RoomType.*; import static com.moabam.global.error.model.ErrorMessage.*; +import java.time.LocalDate; +import java.time.Period; +import java.util.ArrayList; import java.util.List; +import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.DailyMemberCertification; +import com.moabam.api.domain.entity.DailyRoomCertification; import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.Routine; import com.moabam.api.domain.entity.enums.RoomType; +import com.moabam.api.domain.repository.CertificationsMapper; +import com.moabam.api.domain.repository.CertificationsSearchRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.api.domain.repository.RoomRepository; import com.moabam.api.domain.repository.RoutineRepository; +import com.moabam.api.domain.repository.RoutineSearchRepository; +import com.moabam.api.dto.CertificationImageResponse; import com.moabam.api.dto.CreateRoomRequest; import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; +import com.moabam.api.dto.RoomDetailsResponse; import com.moabam.api.dto.RoomMapper; +import com.moabam.api.dto.RoutineMapper; +import com.moabam.api.dto.RoutineResponse; +import com.moabam.api.dto.TodayCertificateRankResponse; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ForbiddenException; import com.moabam.global.error.exception.NotFoundException; @@ -35,14 +50,16 @@ public class RoomService { private final RoomRepository roomRepository; private final RoutineRepository routineRepository; + private final RoutineSearchRepository routineSearchRepository; private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; + private final CertificationsSearchRepository certificationsSearchRepository; private final MemberService memberService; @Transactional public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { Room room = RoomMapper.toRoomEntity(createRoomRequest); - List routines = RoomMapper.toRoutineEntity(room, createRoomRequest.routines()); + List routines = RoutineMapper.toRoutineEntities(room, createRoomRequest.routines()); Participant participant = Participant.builder() .room(room) .memberId(memberId) @@ -62,8 +79,9 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); } - Room room = getRoom(roomId); + Room room = participant.getRoom(); room.changeTitle(modifyRoomRequest.title()); + room.changeAnnouncement(modifyRoomRequest.announcement()); room.changePassword(modifyRoomRequest.password()); room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); @@ -71,7 +89,7 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR @Transactional public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequest) { - Room room = getRoom(roomId); + Room room = roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); validateRoomEnter(memberId, enterRoomRequest.password(), room); room.increaseCurrentUserCount(); @@ -95,7 +113,6 @@ public void exitRoom(Long memberId, Long roomId) { decreaseRoomCount(memberId, room.getRoomType()); participant.removeRoom(); - participantRepository.flush(); participantRepository.delete(participant); if (!participant.isManager()) { @@ -107,6 +124,25 @@ public void exitRoom(Long memberId, Long roomId) { roomRepository.delete(room); } + public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { + LocalDate today = LocalDate.now(); + Participant participant = getParticipant(memberId, roomId); + Room room = participant.getRoom(); + + String managerNickname = memberService.getManager(roomId).getNickname(); + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + List routineResponses = getRoutineResponses(roomId); + List todayCertificateRankResponses = getTodayCertificateRankResponses(roomId, + routineResponses, dailyMemberCertifications, today); + List certifiedDates = getCertifiedDates(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + return RoomMapper.toRoomDetailsResponse(room, managerNickname, routineResponses, certifiedDates, + todayCertificateRankResponses, completePercentage); + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); @@ -114,23 +150,17 @@ public void validateRoomById(Long roomId) { } private Participant getParticipant(Long memberId, Long roomId) { - return participantSearchRepository.findParticipant(memberId, roomId) + return participantSearchRepository.findOne(memberId, roomId) .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); } - private Room getRoom(Long roomId) { - return roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); - } - private void validateRoomEnter(Long memberId, String requestPassword, Room room) { if (!isEnterRoomAvailable(memberId, room.getRoomType())) { throw new BadRequestException(MEMBER_ROOM_EXCEED); } - 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); } @@ -142,7 +172,6 @@ private boolean isEnterRoomAvailable(Long memberId, RoomType roomType) { if (roomType.equals(MORNING) && member.getCurrentMorningCount() >= 3) { return false; } - if (roomType.equals(NIGHT) && member.getCurrentNightCount() >= 3) { return false; } @@ -171,4 +200,108 @@ private void decreaseRoomCount(Long memberId, RoomType roomType) { member.exitNightRoom(); } + + private List getRoutineResponses(Long roomId) { + List roomRoutines = routineSearchRepository.findByRoomId(roomId); + + return RoutineMapper.toRoutineResponses(roomRoutines); + } + + private List getTodayCertificateRankResponses(Long roomId, + List routines, List dailyMemberCertifications, LocalDate today) { + + List responses = new ArrayList<>(); + List routineIds = routines.stream() + .map(RoutineResponse::routineId) + .toList(); + List certifications = certificationsSearchRepository.findCertifications( + routineIds, + today); + List participants = participantSearchRepository.findParticipants(roomId); + List members = memberService.getRoomMembers(participants.stream() + .map(Participant::getMemberId) + .toList()); + + addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, today); + addUncompletedMembers(responses, dailyMemberCertifications, members, participants, today); + + return responses; + } + + private void addCompletedMembers(List responses, + List dailyMemberCertifications, List members, + List certifications, List participants, LocalDate today) { + + 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)); + + int contributionPoint = calculateContributionPoint(member.getId(), participants, today); + List certificationImageResponses = + CertificationsMapper.toCertificateImageResponses(member.getId(), certifications); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( + rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses); + + rank += 1; + responses.add(response); + } + } + + private void addUncompletedMembers(List responses, + List dailyMemberCertifications, List members, + List participants, LocalDate today) { + + List allMemberIds = participants.stream() + .map(Participant::getMemberId) + .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)); + + int contributionPoint = calculateContributionPoint(memberId, participants, today); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( + 500, member, contributionPoint, "https://~awake", "https://~sleep", null); + + responses.add(response); + } + } + + private int calculateContributionPoint(Long memberId, List participants, LocalDate today) { + Participant participant = participants.stream() + .filter(p -> p.getMemberId().equals(memberId)) + .findAny() + .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); + + int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), today).getDays() + 1; + + return (int)(((double)participant.getCertifyCount() / participatedDays) * 100); + } + + private List getCertifiedDates(Long roomId, LocalDate today) { + List certifications = certificationsSearchRepository.findDailyRoomCertifications( + roomId, today); + + return certifications.stream().map(DailyRoomCertification::getCertifiedAt).toList(); + } + + private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { + double completePercentage = ((double)certifiedMembersCount / currentsMembersCount) * 100; + + return Math.round(completePercentage * 100) / 100.0; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java b/src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java new file mode 100644 index 00000000..1b9f6d19 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java @@ -0,0 +1,49 @@ +package com.moabam.api.domain.entity; + +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/entity/DailyRoomCertification.java b/src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java new file mode 100644 index 00000000..2e345b8a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java @@ -0,0 +1,41 @@ +package com.moabam.api.domain.entity; + +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/entity/Participant.java b/src/main/java/com/moabam/api/domain/entity/Participant.java index 7c4442a8..09762b85 100644 --- a/src/main/java/com/moabam/api/domain/entity/Participant.java +++ b/src/main/java/com/moabam/api/domain/entity/Participant.java @@ -6,6 +6,8 @@ import org.hibernate.annotations.SQLDelete; +import com.moabam.global.common.entity.BaseTimeEntity; + import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -25,7 +27,7 @@ @Table(name = "participant") @SQLDelete(sql = "UPDATE participant SET deleted_at = CURRENT_TIMESTAMP where id = ?") @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class Participant { +public class Participant extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -33,7 +35,7 @@ public class Participant { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id", updatable = false) + @JoinColumn(name = "room_id") private Room room; @Column(name = "member_id", updatable = false, nullable = false) diff --git a/src/main/java/com/moabam/api/domain/entity/Routine.java b/src/main/java/com/moabam/api/domain/entity/Routine.java index 26015bb2..23dd4773 100644 --- a/src/main/java/com/moabam/api/domain/entity/Routine.java +++ b/src/main/java/com/moabam/api/domain/entity/Routine.java @@ -30,7 +30,7 @@ public class Routine extends BaseTimeEntity { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "room_id", nullable = false, updatable = false) + @JoinColumn(name = "room_id", updatable = false) private Room room; @Column(name = "content", nullable = false, length = 60) diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java b/src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java new file mode 100644 index 00000000..0bba80ec --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java @@ -0,0 +1,50 @@ +package com.moabam.api.domain.repository; + +import java.util.ArrayList; +import java.util.List; + +import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.Member; +import com.moabam.api.dto.CertificationImageResponse; +import com.moabam.api.dto.TodayCertificateRankResponse; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CertificationsMapper { + + public static List toCertificateImageResponses(Long memberId, + List certifications) { + List cftImageResponses = new ArrayList<>(); + List filteredCertifications = certifications.stream() + .filter(certification -> certification.getMemberId().equals(memberId)) + .toList(); + + for (Certification certification : filteredCertifications) { + CertificationImageResponse cftImageResponse = CertificationImageResponse.builder() + .routineId(certification.getRoutine().getId()) + .image(certification.getImage()) + .build(); + + cftImageResponses.add(cftImageResponse); + } + + return cftImageResponses; + } + + public static TodayCertificateRankResponse toTodayCertificateRankResponse(int rank, Member member, + int contributionPoint, String awakeImage, String sleepImage, + List certificationImageResponses) { + return TodayCertificateRankResponse.builder() + .rank(rank) + .memberId(member.getId()) + .nickname(member.getNickname()) + .profileImage(member.getProfileImage()) + .contributionPoint(contributionPoint) + .awakeImage(awakeImage) + .sleepImage(sleepImage) + .certificationImage(certificationImageResponses) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java new file mode 100644 index 00000000..70c8a8cf --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java @@ -0,0 +1,68 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QCertification.*; +import static com.moabam.api.domain.entity.QDailyMemberCertification.*; +import static com.moabam.api.domain.entity.QDailyRoomCertification.*; +import static com.moabam.api.domain.entity.QParticipant.*; + +import java.time.LocalDate; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.DailyMemberCertification; +import com.moabam.api.domain.entity.DailyRoomCertification; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CertificationsSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findCertifications(List routineIds, LocalDate date) { + BooleanExpression expression = null; + + for (Long routineId : routineIds) { + BooleanExpression routineExpression = certification.routine.id.eq(routineId); + expression = expression == null ? routineExpression : expression.or(routineExpression); + } + + return jpaQueryFactory + .selectFrom(certification) + .where( + expression, + certification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) + ) + .fetch(); + } + + 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 List findDailyRoomCertifications(Long roomId, LocalDate date) { + return jpaQueryFactory + .selectFrom(dailyRoomCertification) + .where( + dailyRoomCertification.roomId.eq(roomId), + dailyRoomCertification.certifiedAt.eq(date) + ) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java b/src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java new file mode 100644 index 00000000..d5c98951 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.DailyMemberCertification; + +public interface DailyMemberCertificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java b/src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java new file mode 100644 index 00000000..baf84d7e --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.DailyRoomCertification; + +public interface DailyRoomCertificationRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java new file mode 100644 index 00000000..9970db48 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java @@ -0,0 +1,33 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QMember.*; +import static com.moabam.api.domain.entity.QParticipant.*; + +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Member; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class MemberSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findManager(Long roomId) { + return Optional.ofNullable( + jpaQueryFactory + .selectFrom(member) + .innerJoin(participant).on(member.id.eq(participant.memberId)) + .where( + participant.isManager.eq(true), + participant.room.id.eq(roomId) + ) + .fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java index 9e3a01b3..91830965 100644 --- a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java @@ -1,7 +1,9 @@ package com.moabam.api.domain.repository; import static com.moabam.api.domain.entity.QParticipant.*; +import static com.moabam.api.domain.entity.QRoom.*; +import java.util.List; import java.util.Optional; import org.springframework.stereotype.Repository; @@ -18,13 +20,25 @@ public class ParticipantSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public Optional findParticipant(Long memberId, Long roomId) { + public Optional findOne(Long memberId, Long roomId) { return Optional.ofNullable( - jpaQueryFactory.selectFrom(participant) + jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() .where( DynamicQuery.generateEq(roomId, participant.room.id::eq), DynamicQuery.generateEq(memberId, participant.memberId::eq) - ).fetchOne() + ) + .fetchOne() ); } + + public List findParticipants(Long roomId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId) + ) + .fetch(); + } } diff --git a/src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java new file mode 100644 index 00000000..ee18d348 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java @@ -0,0 +1,28 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QRoutine.*; + +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Routine; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class RoutineSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List findByRoomId(Long roomId) { + return jpaQueryFactory + .selectFrom(routine) + .where( + routine.room.id.eq(roomId) + ) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/dto/CertificationImageResponse.java b/src/main/java/com/moabam/api/dto/CertificationImageResponse.java new file mode 100644 index 00000000..33d96acb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CertificationImageResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record CertificationImageResponse( + Long routineId, + String image +) { + +} diff --git a/src/main/java/com/moabam/api/dto/NotificationMapper.java b/src/main/java/com/moabam/api/dto/NotificationMapper.java index f51016be..79795e15 100644 --- a/src/main/java/com/moabam/api/dto/NotificationMapper.java +++ b/src/main/java/com/moabam/api/dto/NotificationMapper.java @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public class NotificationMapper { +public final class NotificationMapper { private static final String TITLE = "모아밤"; private static final String KNOCK_BODY = "님이 콕 찔렀습니다."; diff --git a/src/main/java/com/moabam/api/dto/OAuthMapper.java b/src/main/java/com/moabam/api/dto/OAuthMapper.java index 6550f32f..a637ff4e 100644 --- a/src/main/java/com/moabam/api/dto/OAuthMapper.java +++ b/src/main/java/com/moabam/api/dto/OAuthMapper.java @@ -6,7 +6,7 @@ import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public class OAuthMapper { +public final class OAuthMapper { public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) { return AuthorizationCodeRequest.builder() diff --git a/src/main/java/com/moabam/api/dto/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/RoomDetailsResponse.java new file mode 100644 index 00000000..b90f8c70 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/RoomDetailsResponse.java @@ -0,0 +1,26 @@ +package com.moabam.api.dto; + +import java.time.LocalDate; +import java.util.List; + +import lombok.Builder; + +@Builder +public record RoomDetailsResponse( + Long roomId, + String title, + String managerNickName, + String roomImage, + int level, + String roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + String announcement, + double completePercentage, + List certifiedDates, + List routine, + List todayCertificateRank +) { + +} diff --git a/src/main/java/com/moabam/api/dto/RoomMapper.java b/src/main/java/com/moabam/api/dto/RoomMapper.java index 9ac332c2..0a97ae0c 100644 --- a/src/main/java/com/moabam/api/dto/RoomMapper.java +++ b/src/main/java/com/moabam/api/dto/RoomMapper.java @@ -1,15 +1,15 @@ package com.moabam.api.dto; +import java.time.LocalDate; import java.util.List; import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.entity.Routine; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public abstract class RoomMapper { +public final class RoomMapper { public static Room toRoomEntity(CreateRoomRequest createRoomRequest) { return Room.builder() @@ -21,12 +21,24 @@ public static Room toRoomEntity(CreateRoomRequest createRoomRequest) { .build(); } - public static List toRoutineEntity(Room room, List routinesRequest) { - return routinesRequest.stream() - .map(routine -> Routine.builder() - .room(room) - .content(routine) - .build()) - .toList(); + public static RoomDetailsResponse toRoomDetailsResponse(Room room, String managerNickname, + List routineResponses, List certifiedDates, + List todayCertificateRankResponses, double completePercentage) { + return RoomDetailsResponse.builder() + .roomId(room.getId()) + .title(room.getTitle()) + .managerNickName(managerNickname) + .roomImage(room.getRoomImage()) + .level(room.getLevel()) + .roomType(room.getRoomType().name()) + .certifyTime(room.getCertifyTime()) + .currentUserCount(room.getCurrentUserCount()) + .maxUserCount(room.getMaxUserCount()) + .announcement(room.getAnnouncement()) + .completePercentage(completePercentage) + .certifiedDates(certifiedDates) + .routine(routineResponses) + .todayCertificateRank(todayCertificateRankResponses) + .build(); } } diff --git a/src/main/java/com/moabam/api/dto/RoutineMapper.java b/src/main/java/com/moabam/api/dto/RoutineMapper.java new file mode 100644 index 00000000..40e941a4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/RoutineMapper.java @@ -0,0 +1,31 @@ +package com.moabam.api.dto; + +import java.util.List; + +import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.Routine; + +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/dto/RoutineResponse.java b/src/main/java/com/moabam/api/dto/RoutineResponse.java new file mode 100644 index 00000000..8f02990b --- /dev/null +++ b/src/main/java/com/moabam/api/dto/RoutineResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record RoutineResponse( + Long routineId, + String content +) { + +} diff --git a/src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java new file mode 100644 index 00000000..970f6877 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java @@ -0,0 +1,19 @@ +package com.moabam.api.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record TodayCertificateRankResponse( + int rank, + Long memberId, + String nickname, + String profileImage, + int contributionPoint, + String awakeImage, + String sleepImage, + List certificationImage +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 077f94f1..8eba8cc3 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -2,6 +2,7 @@ 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; @@ -14,6 +15,7 @@ import com.moabam.api.dto.CreateRoomRequest; import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; +import com.moabam.api.dto.RoomDetailsResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -49,4 +51,10 @@ public void enterRoom(@Valid @RequestBody EnterRoomRequest enterRoomRequest, @Pa public void exitRoom(@PathVariable("roomId") Long roomId) { roomService.exitRoom(1L, roomId); } + + @GetMapping("/{roomId}") + @ResponseStatus(HttpStatus.OK) + public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { + return roomService.getRoomDetails(1L, roomId); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index faa4a89f..f6ffb13c 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -20,6 +20,7 @@ public enum ErrorMessage { PARTICIPANT_NOT_FOUND("방에 대한 참여자의 정보가 없습니다."), WRONG_ROOM_PASSWORD("방의 비밀번호가 일치하지 않습니다."), ROOM_MAX_USER_REACHED("방의 인원수가 찼습니다."), + ROOM_DETAILS_ERROR("방 정보를 불러오는데 실패했습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 31ce6517..06a849a6 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -7,6 +7,7 @@ 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; @@ -24,9 +25,16 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.DailyMemberCertification; +import com.moabam.api.domain.entity.DailyRoomCertification; import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.repository.CertificationRepository; +import com.moabam.api.domain.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.repository.DailyRoomCertificationRepository; import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.RoomRepository; @@ -61,6 +69,15 @@ class RoomControllerTest { @Autowired private MemberRepository memberRepository; + @Autowired + private CertificationRepository certificationRepository; + + @Autowired + private DailyMemberCertificationRepository dailyMemberCertificationRepository; + + @Autowired + private DailyRoomCertificationRepository dailyRoomCertificationRepository; + Member member; @BeforeAll @@ -587,10 +604,10 @@ void no_manager_exit_room_success() throws Exception { .andDo(print()); participantRepository.flush(); + Room findRoom = roomRepository.findById(room.getId()).orElseThrow(); Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); - assertThat(room.getCurrentUserCount()).isEqualTo(4); + assertThat(findRoom.getCurrentUserCount()).isEqualTo(4); assertThat(deletedParticipant.getDeletedAt()).isNotNull(); - assertThat(deletedParticipant.getDeletedRoomTitle()).isEqualTo("5명이 있는 방~"); } @DisplayName("방장의 방 나가기 - 방 삭제 성공") @@ -615,6 +632,7 @@ void manager_delete_room_success() throws Exception { mockMvc.perform(delete("/rooms/" + room.getId())) .andExpect(status().isOk()) .andDo(print()); + Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); assertThat(roomRepository.findById(room.getId())).isEmpty(); assertThat(deletedParticipant.getDeletedAt()).isNotNull(); @@ -720,4 +738,105 @@ void exit_and_decrease_night_room_count() throws Exception { // then assertThat(getMember.getCurrentNightCount()).isEqualTo(2); } + + @DisplayName("방 상세 정보 조회 성공 테스트") + @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(); + + Routine routine1 = Routine.builder() + .room(room) + .content("물 마시기") + .build(); + + Routine routine2 = Routine.builder() + .room(room) + .content("코테 풀기") + .build(); + + Participant participant1 = Participant.builder() + .room(room) + .memberId(1L) + .build(); + participant1.enableManager(); + + Member member2 = Member.builder() + .socialId("SOCIAL_2") + .nickname("NICKNAME_2") + .profileImage("PROFILE_IMAGE_2") + .bug(BugFixture.bug()) + .build(); + + Member member3 = Member.builder() + .socialId("SOCIAL_3") + .nickname("NICKNAME_3") + .profileImage("PROFILE_IMAGE_3") + .bug(BugFixture.bug()) + .build(); + + roomRepository.save(room); + routineRepository.save(routine1); + routineRepository.save(routine2); + memberRepository.save(member2); + memberRepository.save(member3); + + Participant participant2 = Participant.builder() + .room(room) + .memberId(member2.getId()) + .build(); + + Participant participant3 = Participant.builder() + .room(room) + .memberId(member3.getId()) + .build(); + + participantRepository.save(participant1); + participantRepository.save(participant2); + participantRepository.save(participant3); + + Certification certification1 = Certification.builder() + .routine(routine1) + .memberId(member.getId()) + .image("member1Image") + .build(); + + Certification certification2 = Certification.builder() + .routine(routine2) + .memberId(member.getId()) + .image("member2Image") + .build(); + + certificationRepository.save(certification1); + certificationRepository.save(certification2); + + DailyMemberCertification dailyMemberCertification = DailyMemberCertification.builder() + .memberId(member.getId()) + .roomId(room.getId()) + .participant(participant1) + .build(); + + dailyMemberCertificationRepository.save(dailyMemberCertification); + + DailyRoomCertification dailyRoomCertification = DailyRoomCertification.builder() + .roomId(room.getId()) + .certifiedAt(LocalDate.now()) + .build(); + + dailyRoomCertificationRepository.save(dailyRoomCertification); + + // expected + mockMvc.perform(get("/rooms/" + room.getId())) + .andExpect(status().isOk()) + .andDo(print()); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 4f6c4f27..3347b41f 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -12,7 +12,7 @@ spring: properties: hibernate: format_sql: true - + highlight_sql: true # Redis data: redis: From 66eda550cd1e6d72277c082cd13ad07b9f568799 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Tue, 7 Nov 2023 16:20:21 +0900 Subject: [PATCH 021/185] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#45)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 아이템 적용 API 구현 * test: 아이템 적용 Service 테스트 * test: Controller 테스트 @WebMvcTest로 변경 * test: 아이템 적용 Controller 테스트 * style: support 패키지 생성 * test: RepositoryTest 어노테이션 생성 및 적용 * test: 동일 메서드 테스트 Nested로 처리 * feat: 현재 적용된 인벤토리 조회 시 아이템 타입 정보 추가 * test: 인벤토리 조회 Repository 테스트 * fix: merge conflict 해결 * test: given-willReturn 으로 변경 * refactor: 메서드 네이밍 수정 * refactor: 어노테이션 네이밍 수정 --- .../moabam/api/application/ItemService.java | 18 +++ .../moabam/api/domain/entity/Inventory.java | 13 ++ .../repository/InventorySearchRepository.java | 23 +++ .../api/presentation/ItemController.java | 8 + .../global/error/model/ErrorMessage.java | 2 + .../AuthenticationServiceTest.java | 2 +- .../api/application/BugServiceTest.java | 4 +- .../api/application/ItemServiceTest.java | 49 +++++- .../api/application/ProductServiceTest.java | 2 +- .../moabam/api/domain/entity/MemberTest.java | 2 +- .../InventorySearchRepositoryTest.java | 100 ++++++++---- .../repository/ItemSearchRepositoryTest.java | 150 +++++++++--------- .../api/presentation/BugControllerTest.java | 20 +-- .../api/presentation/ItemControllerTest.java | 37 +++-- .../presentation/MemberControllerTest.java | 2 +- .../NotificationControllerTest.java | 2 +- .../presentation/ProductControllerTest.java | 22 +-- .../api/presentation/RoomControllerTest.java | 4 +- .../annotation/QuerydslRepositoryTest.java | 19 +++ .../support/config/TestQuerydslConfig.java | 35 ++++ .../fixture/AuthorizationResponseFixture.java | 2 +- .../{ => support}/fixture/BugFixture.java | 2 +- .../fixture/InventoryFixture.java | 2 +- .../{ => support}/fixture/ItemFixture.java | 2 +- .../{ => support}/fixture/MemberFixture.java | 2 +- .../{ => support}/fixture/ProductFixture.java | 2 +- 26 files changed, 376 insertions(+), 150 deletions(-) create mode 100644 src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java create mode 100644 src/test/java/com/moabam/support/config/TestQuerydslConfig.java rename src/test/java/com/moabam/{ => support}/fixture/AuthorizationResponseFixture.java (96%) rename src/test/java/com/moabam/{ => support}/fixture/BugFixture.java (90%) rename src/test/java/com/moabam/{ => support}/fixture/InventoryFixture.java (88%) rename src/test/java/com/moabam/{ => support}/fixture/ItemFixture.java (97%) rename src/test/java/com/moabam/{ => support}/fixture/MemberFixture.java (94%) rename src/test/java/com/moabam/{ => support}/fixture/ProductFixture.java (93%) diff --git a/src/main/java/com/moabam/api/application/ItemService.java b/src/main/java/com/moabam/api/application/ItemService.java index 9d9aca77..4ba1203e 100644 --- a/src/main/java/com/moabam/api/application/ItemService.java +++ b/src/main/java/com/moabam/api/application/ItemService.java @@ -1,16 +1,20 @@ package com.moabam.api.application; +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.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.api.domain.repository.InventorySearchRepository; import com.moabam.api.domain.repository.ItemSearchRepository; import com.moabam.api.dto.ItemMapper; import com.moabam.api.dto.ItemsResponse; +import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -28,4 +32,18 @@ public ItemsResponse getItems(Long memberId, ItemType type) { return ItemMapper.toItemsResponse(purchasedItems, notPurchasedItems); } + + @Transactional + public void selectItem(Long memberId, Long itemId) { + Inventory inventory = getInventory(memberId, itemId); + + inventorySearchRepository.findDefault(memberId, inventory.getItemType()) + .ifPresent(Inventory::deselect); + inventory.select(); + } + + private Inventory getInventory(Long memberId, Long itemId) { + return inventorySearchRepository.findOne(memberId, itemId) + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Inventory.java b/src/main/java/com/moabam/api/domain/entity/Inventory.java index 4921ab7d..dd493564 100644 --- a/src/main/java/com/moabam/api/domain/entity/Inventory.java +++ b/src/main/java/com/moabam/api/domain/entity/Inventory.java @@ -4,6 +4,7 @@ import org.hibernate.annotations.ColumnDefault; +import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.global.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -49,4 +50,16 @@ private Inventory(Long memberId, Item item, boolean isDefault) { this.item = requireNonNull(item); this.isDefault = isDefault; } + + public ItemType getItemType() { + return this.item.getType(); + } + + public void select() { + this.isDefault = true; + } + + public void deselect() { + this.isDefault = false; + } } diff --git a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java index e15c017c..44a17bda 100644 --- a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java @@ -4,9 +4,11 @@ import static com.moabam.api.domain.entity.QItem.*; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Repository; +import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.global.common.util.DynamicQuery; @@ -20,6 +22,27 @@ 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) diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java index 47bd5880..3c297d3b 100644 --- a/src/main/java/com/moabam/api/presentation/ItemController.java +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -2,6 +2,8 @@ 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; @@ -25,4 +27,10 @@ public class ItemController { public ItemsResponse getItems(@RequestParam ItemType type) { return itemService.getItems(1L, type); } + + @PostMapping("/{itemId}/select") + @ResponseStatus(HttpStatus.OK) + public void selectItem(@PathVariable Long itemId) { + itemService.selectItem(1L, itemId); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index f6ffb13c..6572021a 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -28,6 +28,8 @@ public enum ErrorMessage { MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), + INVENTORY_NOT_FOUND("구매하지 않은 아이템은 적용할 수 없습니다."), + INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), INVALID_PRICE("가격은 0 이상이어야 합니다."), INVALID_QUANTITY("수량은 1 이상이어야 합니다."), diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index cd884e1a..deb900f6 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -26,10 +26,10 @@ import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.AuthorizationResponseFixture; @ExtendWith(MockitoExtension.class) class AuthenticationServiceTest { diff --git a/src/test/java/com/moabam/api/application/BugServiceTest.java b/src/test/java/com/moabam/api/application/BugServiceTest.java index 416ba5fb..72c06ae6 100644 --- a/src/test/java/com/moabam/api/application/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/BugServiceTest.java @@ -1,5 +1,6 @@ package com.moabam.api.application; +import static com.moabam.support.fixture.MemberFixture.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -13,7 +14,6 @@ import com.moabam.api.domain.entity.Bug; import com.moabam.api.domain.entity.Member; import com.moabam.api.dto.BugResponse; -import com.moabam.fixture.MemberFixture; @ExtendWith(MockitoExtension.class) class BugServiceTest { @@ -29,7 +29,7 @@ class BugServiceTest { void get_bug_success() { // given Long memberId = 1L; - Member member = MemberFixture.member(); + Member member = member(); given(memberService.getById(memberId)).willReturn(member); // when diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/ItemServiceTest.java index cc2b0a43..b07741a8 100644 --- a/src/test/java/com/moabam/api/application/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/ItemServiceTest.java @@ -1,19 +1,23 @@ package com.moabam.api.application; -import static com.moabam.fixture.ItemFixture.*; +import static com.moabam.support.fixture.ItemFixture.*; 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.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.api.domain.repository.InventorySearchRepository; @@ -21,6 +25,8 @@ import com.moabam.api.dto.ItemResponse; import com.moabam.api.dto.ItemsResponse; import com.moabam.global.common.util.StreamUtils; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.support.fixture.InventoryFixture; @ExtendWith(MockitoExtension.class) class ItemServiceTest { @@ -54,4 +60,45 @@ void get_products_success() { assertThat(purchasedItemNames).containsExactly(MORNING_SANTA_SKIN_NAME, MORNING_KILLER_SKIN_NAME); assertThat(response.notPurchasedItems()).isEmpty(); } + + @DisplayName("아이템을 적용한다.") + @Nested + class SelectItem { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Long memberId = 1L; + Long itemId = 1L; + Inventory inventory = InventoryFixture.inventory(memberId, nightMageSkin()); + Inventory defaultInventory = InventoryFixture.inventory(memberId, nightMageSkin()); + ItemType itemType = inventory.getItemType(); + given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.of(inventory)); + given(inventorySearchRepository.findDefault(memberId, itemType)).willReturn(Optional.of(defaultInventory)); + + // when + itemService.selectItem(memberId, itemId); + + // then + verify(inventorySearchRepository).findOne(memberId, itemId); + verify(inventorySearchRepository).findDefault(memberId, itemType); + 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/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java index bb63597f..c4555b94 100644 --- a/src/test/java/com/moabam/api/application/ProductServiceTest.java +++ b/src/test/java/com/moabam/api/application/ProductServiceTest.java @@ -1,6 +1,6 @@ package com.moabam.api.application; -import static com.moabam.fixture.ProductFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index 1a947ac8..ab0df828 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -8,8 +8,8 @@ import org.junit.jupiter.api.Test; import com.moabam.api.domain.entity.enums.Role; -import com.moabam.fixture.MemberFixture; import com.moabam.global.common.util.BaseImageUrl; +import com.moabam.support.fixture.MemberFixture; class MemberTest { diff --git a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java index 83463363..c8e78153 100644 --- a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java @@ -1,67 +1,109 @@ package com.moabam.api.domain.repository; -import static com.moabam.fixture.InventoryFixture.*; -import static com.moabam.fixture.ItemFixture.*; +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 org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.support.annotation.QuerydslRepositoryTest; -@SpringBootTest -@Transactional(readOnly = true) +@QuerydslRepositoryTest class InventorySearchRepositoryTest { + @Autowired + InventorySearchRepository inventorySearchRepository; + + @Autowired + MemberRepository memberRepository; + @Autowired ItemRepository itemRepository; @Autowired InventoryRepository inventoryRepository; - @Autowired - InventorySearchRepository inventorySearchRepository; + @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("타입으로 인벤토리에 있는 아이템 목록을 구매일 순으로 조회한다.") + @DisplayName("인벤토리를 조회한다.") @Test - void find_items_success() { + void find_one_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)); + Member member = memberRepository.save(member()); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); // when - List actual = inventorySearchRepository.findItems(memberId, ItemType.MORNING); + Optional actual = inventorySearchRepository.findOne(member.getId(), item.getId()); // then - assertThat(actual).hasSize(2) - .containsExactly(morningKillerSkin, morningSantaSkin); + assertThat(actual).isPresent().contains(inventory); } - @DisplayName("인벤토리에 해당하는 타입의 아이템이 없으면 빈 목록을 조회한다.") + @DisplayName("현재 적용된 인벤토리를 조회한다.") @Test - void find_empty_success() { + void find_default_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)); + Member member = memberRepository.save(member()); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); + inventory.select(); // when - List actual = inventorySearchRepository.findItems(memberId, ItemType.NIGHT); + Optional actual = inventorySearchRepository.findDefault(member.getId(), inventory.getItemType()); // then - assertThat(actual).isEmpty(); + assertThat(actual).isPresent().contains(inventory); } } diff --git a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java index c4d41b15..a1bc7751 100644 --- a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java @@ -1,96 +1,100 @@ package com.moabam.api.domain.repository; -import static com.moabam.fixture.InventoryFixture.*; -import static com.moabam.fixture.ItemFixture.*; +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 org.springframework.boot.test.context.SpringBootTest; -import org.springframework.transaction.annotation.Transactional; import com.moabam.api.domain.entity.Item; import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.support.annotation.QuerydslRepositoryTest; -@SpringBootTest -@Transactional(readOnly = true) +@QuerydslRepositoryTest class ItemSearchRepositoryTest { @Autowired - ItemRepository itemRepository; + ItemSearchRepository itemSearchRepository; @Autowired - InventoryRepository inventoryRepository; + ItemRepository itemRepository; @Autowired - ItemSearchRepository itemSearchRepository; - - @DisplayName("타입으로 구매하지 않은 아이템 목록을 조회한다.") - @Test - void find_not_purchased_items_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 find_not_purchased_items_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 find_not_purchased_items_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 find_not_purchased_items_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); + InventoryRepository inventoryRepository; - // then - assertThat(actual).hasSize(2) - .containsExactly(morningSantaSkin, morningKillerSkin); + @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/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 4bfedb04..4a78621a 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -1,17 +1,18 @@ package com.moabam.api.presentation; +import static com.moabam.support.fixture.BugFixture.*; 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 org.assertj.core.api.Assertions; 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.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; @@ -19,10 +20,8 @@ import com.moabam.api.application.BugService; import com.moabam.api.dto.BugMapper; import com.moabam.api.dto.BugResponse; -import com.moabam.fixture.BugFixture; -@SpringBootTest -@AutoConfigureMockMvc +@WebMvcTest(BugController.class) class BugControllerTest { @Autowired @@ -39,17 +38,18 @@ class BugControllerTest { void get_bug_success() throws Exception { // given Long memberId = 1L; - BugResponse expected = BugMapper.toBugResponse(BugFixture.bug()); + BugResponse expected = BugMapper.toBugResponse(bug()); given(bugService.getBug(memberId)).willReturn(expected); - // expected - String content = mockMvc.perform(get("/bugs")) + // when, then + String content = mockMvc.perform(get("/bugs") + .contentType(APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()) .andReturn() .getResponse() .getContentAsString(UTF_8); BugResponse actual = objectMapper.readValue(content, BugResponse.class); - Assertions.assertThat(actual).isEqualTo(expected); + assertThat(actual).isEqualTo(expected); } } diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index eee2e9a4..383a8e5b 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -1,20 +1,21 @@ package com.moabam.api.presentation; +import static com.moabam.support.fixture.ItemFixture.*; import static java.nio.charset.StandardCharsets.*; import static java.util.Collections.*; +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.assertj.core.api.Assertions; 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.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; @@ -24,10 +25,8 @@ import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.api.dto.ItemMapper; import com.moabam.api.dto.ItemsResponse; -import com.moabam.fixture.ItemFixture; -@SpringBootTest -@AutoConfigureMockMvc +@WebMvcTest(ItemController.class) class ItemControllerTest { @Autowired @@ -45,20 +44,36 @@ void get_items_success() throws Exception { // given Long memberId = 1L; ItemType type = ItemType.MORNING; - Item item1 = ItemFixture.morningSantaSkin().build(); - Item item2 = ItemFixture.morningKillerSkin().build(); + Item item1 = morningSantaSkin().build(); + Item item2 = morningKillerSkin().build(); ItemsResponse expected = ItemMapper.toItemsResponse(List.of(item1, item2), emptyList()); given(itemService.getItems(memberId, type)).willReturn(expected); - // expected + // when, then String content = mockMvc.perform(get("/items") - .param("type", ItemType.MORNING.name())) + .param("type", ItemType.MORNING.name()) + .contentType(APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()) .andReturn() .getResponse() .getContentAsString(UTF_8); ItemsResponse actual = objectMapper.readValue(content, ItemsResponse.class); - Assertions.assertThat(actual).isEqualTo(expected); + assertThat(actual).isEqualTo(expected); + } + + @DisplayName("아이템을 적용한다.") + @Test + void select_item_success() throws Exception { + // given + Long memberId = 1L; + Long itemId = 1L; + + // when, then + mockMvc.perform(post("/items/{itemId}/select", itemId) + .contentType(APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + verify(itemService).selectItem(memberId, itemId); } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 40a5f18d..2ecf1883 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -38,10 +38,10 @@ import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.fixture.AuthorizationResponseFixture; import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.handler.RestTemplateResponseHandler; +import com.moabam.support.fixture.AuthorizationResponseFixture; @SpringBootTest @AutoConfigureMockMvc diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 5259d56c..7914b949 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -27,9 +27,9 @@ import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.domain.repository.RoomRepository; -import com.moabam.fixture.MemberFixture; import com.moabam.fixture.RoomFixture; import com.moabam.global.common.repository.StringRedisRepository; +import com.moabam.support.fixture.MemberFixture; @Transactional @SpringBootTest diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index a14c21c8..554b9b6b 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -1,19 +1,20 @@ package com.moabam.api.presentation; +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.assertj.core.api.Assertions; 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.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.web.servlet.MockMvc; @@ -22,10 +23,8 @@ import com.moabam.api.domain.entity.Product; import com.moabam.api.dto.ProductMapper; import com.moabam.api.dto.ProductsResponse; -import com.moabam.fixture.ProductFixture; -@SpringBootTest -@AutoConfigureMockMvc +@WebMvcTest(ProductController.class) class ProductControllerTest { @Autowired @@ -41,19 +40,20 @@ class ProductControllerTest { @Test void get_products_success() throws Exception { // given - Product product1 = ProductFixture.bugProduct(); - Product product2 = ProductFixture.bugProduct(); + Product product1 = bugProduct(); + Product product2 = bugProduct(); ProductsResponse expected = ProductMapper.toProductsResponse(List.of(product1, product2)); given(productService.getProducts()).willReturn(expected); - // expected - String content = mockMvc.perform(get("/products")) + // when, then + String content = mockMvc.perform(get("/products") + .contentType(APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()) .andReturn() .getResponse() .getContentAsString(UTF_8); ProductsResponse actual = objectMapper.readValue(content, ProductsResponse.class); - Assertions.assertThat(actual).isEqualTo(expected); + assertThat(actual).isEqualTo(expected); } } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 06a849a6..ebcc4622 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -42,8 +42,8 @@ import com.moabam.api.dto.CreateRoomRequest; import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; -import com.moabam.fixture.BugFixture; -import com.moabam.fixture.MemberFixture; +import com.moabam.support.fixture.BugFixture; +import com.moabam.support.fixture.MemberFixture; @Transactional @SpringBootTest 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..254439c4 --- /dev/null +++ b/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.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 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 +public @interface QuerydslRepositoryTest { + +} 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..fb7d6d8f --- /dev/null +++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java @@ -0,0 +1,35 @@ +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.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemSearchRepository; +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()); + } +} diff --git a/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java similarity index 96% rename from src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java rename to src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java index 1dc1f3d6..c1671fb7 100644 --- a/src/test/java/com/moabam/fixture/AuthorizationResponseFixture.java +++ b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenInfoResponse; diff --git a/src/test/java/com/moabam/fixture/BugFixture.java b/src/test/java/com/moabam/support/fixture/BugFixture.java similarity index 90% rename from src/test/java/com/moabam/fixture/BugFixture.java rename to src/test/java/com/moabam/support/fixture/BugFixture.java index 8d2b8703..91eb772e 100644 --- a/src/test/java/com/moabam/fixture/BugFixture.java +++ b/src/test/java/com/moabam/support/fixture/BugFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Bug; diff --git a/src/test/java/com/moabam/fixture/InventoryFixture.java b/src/test/java/com/moabam/support/fixture/InventoryFixture.java similarity index 88% rename from src/test/java/com/moabam/fixture/InventoryFixture.java rename to src/test/java/com/moabam/support/fixture/InventoryFixture.java index 357443dc..9c060c20 100644 --- a/src/test/java/com/moabam/fixture/InventoryFixture.java +++ b/src/test/java/com/moabam/support/fixture/InventoryFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; diff --git a/src/test/java/com/moabam/fixture/ItemFixture.java b/src/test/java/com/moabam/support/fixture/ItemFixture.java similarity index 97% rename from src/test/java/com/moabam/fixture/ItemFixture.java rename to src/test/java/com/moabam/support/fixture/ItemFixture.java index 3d95487f..73fb9e78 100644 --- a/src/test/java/com/moabam/fixture/ItemFixture.java +++ b/src/test/java/com/moabam/support/fixture/ItemFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Item; import com.moabam.api.domain.entity.enums.ItemCategory; diff --git a/src/test/java/com/moabam/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java similarity index 94% rename from src/test/java/com/moabam/fixture/MemberFixture.java rename to src/test/java/com/moabam/support/fixture/MemberFixture.java index d8f424c1..80bb1b03 100644 --- a/src/test/java/com/moabam/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Member; diff --git a/src/test/java/com/moabam/fixture/ProductFixture.java b/src/test/java/com/moabam/support/fixture/ProductFixture.java similarity index 93% rename from src/test/java/com/moabam/fixture/ProductFixture.java rename to src/test/java/com/moabam/support/fixture/ProductFixture.java index 91170206..e8b2b3fd 100644 --- a/src/test/java/com/moabam/fixture/ProductFixture.java +++ b/src/test/java/com/moabam/support/fixture/ProductFixture.java @@ -1,4 +1,4 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Product; import com.moabam.api.domain.entity.enums.ProductType; From 3305dff264ed6a9545031708283f7babd38f67a5 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Tue, 7 Nov 2023 16:55:14 +0900 Subject: [PATCH 022/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20(#49)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 방 상세 정보 조회 부분 리팩터링 * refactor: Mapper 위치 변경 * refactor: 방 관련 기능 수정 * refactor: createRoom roomId 반환하도록 refactor --- .../moabam/api/application/RoomService.java | 25 +++++++++++-------- .../com/moabam/api/domain/entity/Room.java | 2 +- .../com/moabam/api/domain/entity/Routine.java | 16 ++++++++++-- .../CertificationsSearchRepository.java | 15 +++-------- .../CertificationsMapper.java | 2 +- .../com/moabam/api/dto/CreateRoomRequest.java | 3 ++- .../com/moabam/api/dto/ModifyRoomRequest.java | 2 +- .../api/presentation/RoomController.java | 4 +-- .../global/error/model/ErrorMessage.java | 1 + .../api/application/RoomServiceTest.java | 16 ++++++++++-- .../api/presentation/RoomControllerTest.java | 4 +-- 11 files changed, 55 insertions(+), 35 deletions(-) rename src/main/java/com/moabam/api/{domain/repository => dto}/CertificationsMapper.java (97%) diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java index 5425f829..9c35148f 100644 --- a/src/main/java/com/moabam/api/application/RoomService.java +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -21,7 +21,6 @@ import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.Routine; import com.moabam.api.domain.entity.enums.RoomType; -import com.moabam.api.domain.repository.CertificationsMapper; import com.moabam.api.domain.repository.CertificationsSearchRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; @@ -29,6 +28,7 @@ import com.moabam.api.domain.repository.RoutineRepository; import com.moabam.api.domain.repository.RoutineSearchRepository; import com.moabam.api.dto.CertificationImageResponse; +import com.moabam.api.dto.CertificationsMapper; import com.moabam.api.dto.CreateRoomRequest; import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; @@ -57,7 +57,7 @@ public class RoomService { private final MemberService memberService; @Transactional - public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { + public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { Room room = RoomMapper.toRoomEntity(createRoomRequest); List routines = RoutineMapper.toRoutineEntities(room, createRoomRequest.routines()); Participant participant = Participant.builder() @@ -66,9 +66,11 @@ public void createRoom(Long memberId, CreateRoomRequest createRoomRequest) { .build(); participant.enableManager(); - roomRepository.save(room); + Room savedRoom = roomRepository.save(room); routineRepository.saveAll(routines); participantRepository.save(participant); + + return savedRoom.getId(); } @Transactional @@ -85,6 +87,12 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR room.changePassword(modifyRoomRequest.password()); room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); + + List routines = routineSearchRepository.findByRoomId(roomId); + routineRepository.deleteAll(routines); + + List newRoutines = RoutineMapper.toRoutineEntities(room, modifyRoomRequest.routines()); + routineRepository.saveAll(newRoutines); } @Transactional @@ -134,7 +142,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); List routineResponses = getRoutineResponses(roomId); List todayCertificateRankResponses = getTodayCertificateRankResponses(roomId, - routineResponses, dailyMemberCertifications, today); + dailyMemberCertifications, today); List certifiedDates = getCertifiedDates(roomId, today); double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), room.getCurrentUserCount()); @@ -208,15 +216,10 @@ private List getRoutineResponses(Long roomId) { } private List getTodayCertificateRankResponses(Long roomId, - List routines, List dailyMemberCertifications, LocalDate today) { + List dailyMemberCertifications, LocalDate today) { List responses = new ArrayList<>(); - List routineIds = routines.stream() - .map(RoutineResponse::routineId) - .toList(); - List certifications = certificationsSearchRepository.findCertifications( - routineIds, - today); + List certifications = certificationsSearchRepository.findCertifications(roomId, today); List participants = participantSearchRepository.findParticipants(roomId); List members = memberService.getRoomMembers(participants.stream() .map(Participant::getMemberId) diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/entity/Room.java index acdd2896..b80d708f 100644 --- a/src/main/java/com/moabam/api/domain/entity/Room.java +++ b/src/main/java/com/moabam/api/domain/entity/Room.java @@ -43,7 +43,7 @@ public class Room extends BaseTimeEntity { @Column(name = "id") private Long id; - @Column(name = "title", nullable = false, length = 30) + @Column(name = "title", nullable = false, length = 20) private String title; @Column(name = "password", length = 8) diff --git a/src/main/java/com/moabam/api/domain/entity/Routine.java b/src/main/java/com/moabam/api/domain/entity/Routine.java index 23dd4773..e370388f 100644 --- a/src/main/java/com/moabam/api/domain/entity/Routine.java +++ b/src/main/java/com/moabam/api/domain/entity/Routine.java @@ -1,8 +1,12 @@ package com.moabam.api.domain.entity; +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; @@ -33,17 +37,25 @@ public class Routine extends BaseTimeEntity { @JoinColumn(name = "room_id", updatable = false) private Room room; - @Column(name = "content", nullable = false, length = 60) + @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 = requireNonNull(content); + 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/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java index 70c8a8cf..ee921314 100644 --- a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java @@ -14,7 +14,6 @@ import com.moabam.api.domain.entity.Certification; import com.moabam.api.domain.entity.DailyMemberCertification; import com.moabam.api.domain.entity.DailyRoomCertification; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -25,18 +24,10 @@ public class CertificationsSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public List findCertifications(List routineIds, LocalDate date) { - BooleanExpression expression = null; - - for (Long routineId : routineIds) { - BooleanExpression routineExpression = certification.routine.id.eq(routineId); - expression = expression == null ? routineExpression : expression.or(routineExpression); - } - - return jpaQueryFactory - .selectFrom(certification) + public List findCertifications(Long roomId, LocalDate date) { + return jpaQueryFactory.selectFrom(certification) .where( - expression, + certification.routine.room.id.eq(roomId), certification.createdAt.between(date.atStartOfDay(), date.atTime(LocalTime.MAX)) ) .fetch(); diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java b/src/main/java/com/moabam/api/dto/CertificationsMapper.java similarity index 97% rename from src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java rename to src/main/java/com/moabam/api/dto/CertificationsMapper.java index 0bba80ec..866ecf40 100644 --- a/src/main/java/com/moabam/api/domain/repository/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/dto/CertificationsMapper.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.dto; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/com/moabam/api/dto/CreateRoomRequest.java b/src/main/java/com/moabam/api/dto/CreateRoomRequest.java index 516564ea..a239943f 100644 --- a/src/main/java/com/moabam/api/dto/CreateRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/CreateRoomRequest.java @@ -2,6 +2,7 @@ import java.util.List; +import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Range; import com.moabam.api.domain.entity.enums.RoomType; @@ -12,7 +13,7 @@ import jakarta.validation.constraints.Size; public record CreateRoomRequest( - @NotBlank String title, + @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, diff --git a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java index ec44c1b5..017fc877 100644 --- a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java @@ -11,7 +11,7 @@ import jakarta.validation.constraints.Size; public record ModifyRoomRequest( - @NotBlank String title, + @NotBlank @Length(max = 20) String title, @Length(max = 255, message = "방 공지의 길이가 너무 깁니다.") String announcement, @NotNull @Size(min = 1, max = 4) List routines, @Pattern(regexp = "^(|\\d{4,8})$") String password, diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 8eba8cc3..f7489136 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -29,8 +29,8 @@ public class RoomController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public void createRoom(@Valid @RequestBody CreateRoomRequest createRoomRequest) { - roomService.createRoom(1L, createRoomRequest); + public Long createRoom(@Valid @RequestBody CreateRoomRequest createRoomRequest) { + return roomService.createRoom(1L, createRoomRequest); } @PutMapping("/{roomId}") diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 6572021a..6205b4ae 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -21,6 +21,7 @@ public enum ErrorMessage { WRONG_ROOM_PASSWORD("방의 비밀번호가 일치하지 않습니다."), ROOM_MAX_USER_REACHED("방의 인원수가 찼습니다."), ROOM_DETAILS_ERROR("방 정보를 불러오는데 실패했습니다."), + ROUTINE_LENGTH_ERROR("루틴의 길이가 잘못 되었습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java index 400d5cbb..dd626f18 100644 --- a/src/test/java/com/moabam/api/application/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java @@ -1,6 +1,7 @@ package com.moabam.api.application; import static com.moabam.api.domain.entity.enums.RoomType.*; +import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; import java.util.ArrayList; @@ -22,6 +23,7 @@ import com.moabam.api.domain.repository.RoomRepository; import com.moabam.api.domain.repository.RoutineRepository; import com.moabam.api.dto.CreateRoomRequest; +import com.moabam.api.dto.RoomMapper; @ExtendWith(MockitoExtension.class) class RoomServiceTest { @@ -52,13 +54,18 @@ void create_room_no_password_success() { CreateRoomRequest createRoomRequest = new CreateRoomRequest( "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); + Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); + given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); + // when - roomService.createRoom(1L, createRoomRequest); + 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("비밀번호 있는 방 생성 성공") @@ -72,12 +79,17 @@ void create_room_with_password_success() { CreateRoomRequest createRoomRequest = new CreateRoomRequest( "재윤과 앵맹이의 방임", "1234", routines, MORNING, 10, 4); + Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); + given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); + // when - roomService.createRoom(1L, createRoomRequest); + 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"); } } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index ebcc4622..8857073e 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -131,7 +131,7 @@ void create_room_with_password_success(String password) throws Exception { routines.add("물 마시기"); routines.add("코테 풀기"); CreateRoomRequest createRoomRequest = new CreateRoomRequest( - "비번 있는 재윤과 앵맹이의 방임", password, routines, MORNING, 10, 4); + "비번 있는 재맹의 방임", password, routines, MORNING, 10, 4); String json = objectMapper.writeValueAsString(createRoomRequest); // expected @@ -141,7 +141,7 @@ void create_room_with_password_success(String password) throws Exception { .andExpect(status().isCreated()) .andDo(print()); assertThat(roomRepository.findAll()).hasSize(1); - assertThat(roomRepository.findAll().get(0).getTitle()).isEqualTo("비번 있는 재윤과 앵맹이의 방임"); + assertThat(roomRepository.findAll().get(0).getTitle()).isEqualTo("비번 있는 재맹의 방임"); assertThat(roomRepository.findAll().get(0).getPassword()).isEqualTo(password); } From 316dafdb7e9c99ae36488ec78272279c64282c66 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Tue, 7 Nov 2023 23:46:26 +0900 Subject: [PATCH 023/185] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#47)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 * feat: 토큰 정보 조회 기능 및 테스트 추가 * feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경 * fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성 * feat: JWT 토큰 제공 서비스 및 테스트 코드 추가 * feat: 토큰 인증 코드 및 테스트 코드 작성 * feat: 로그인 및 회원가입 기능 추가 - 회원의 socialId string -> long으로 변경 * feat: 회원 로그인 테스트 코드 추가 * chore: 코드 포메팅 재 설정 * feat: config 파일 업데이트 * feat: Window용 포트 redis 포트 변경 추가 * refacotr: develop 업데이트 사항 merge * refactor: develop 업데이트 부분 merge * fix: TimeConfig 삭제 및 코드 스멜 변경 * refactor: 코르리뷰 반영 --- build.gradle | 8 + .../application/AuthenticationService.java | 54 ++++-- .../application/JwtAuthenticationService.java | 43 +++++ .../api/application/JwtProviderService.java | 41 +++++ .../moabam/api/application/MemberService.java | 28 +++ ...uth2AuthorizationServerRequestService.java | 3 +- .../com/moabam/api/domain/entity/Member.java | 8 +- .../domain/repository/MemberRepository.java | 3 + .../repository/NotificationRepository.java | 2 +- .../com/moabam/api/dto/LoginResponse.java | 11 ++ .../java/com/moabam/api/dto/MemberMapper.java | 32 ++++ .../api/presentation/MemberController.java | 13 +- .../common/constant/GlobalConstant.java | 2 - .../global/common/constant/RedisConstant.java | 14 -- .../global/common/util/CookieUtils.java | 18 ++ .../common/util/OAuthParameterNames.java | 16 -- .../global/common/util/TokenConstant.java | 13 -- .../global/config/EmbeddedRedisConfig.java | 160 ++++++++++++++---- .../com/moabam/global/config/RedisConfig.java | 2 + .../com/moabam/global/config/TokenConfig.java | 28 +++ .../global/error/model/ErrorMessage.java | 1 + src/main/resources/config | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 4 +- .../AuthenticationServiceTest.java | 58 ++++++- .../JwtAuthenticationServiceTest.java | 108 ++++++++++++ .../application/JwtProviderServiceTest.java | 125 ++++++++++++++ .../api/application/MemberServiceTest.java | 70 ++++++++ .../moabam/api/domain/entity/MemberTest.java | 4 +- .../presentation/MemberControllerTest.java | 88 +++++----- .../NotificationControllerTest.java | 2 +- .../api/presentation/RoomControllerTest.java | 9 +- .../moabam/support/fixture/MemberFixture.java | 7 +- src/test/resources/application.yml | 7 +- 34 files changed, 808 insertions(+), 178 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/JwtAuthenticationService.java create mode 100644 src/main/java/com/moabam/api/application/JwtProviderService.java create mode 100644 src/main/java/com/moabam/api/dto/LoginResponse.java create mode 100644 src/main/java/com/moabam/api/dto/MemberMapper.java delete mode 100644 src/main/java/com/moabam/global/common/constant/RedisConstant.java create mode 100644 src/main/java/com/moabam/global/common/util/CookieUtils.java delete mode 100644 src/main/java/com/moabam/global/common/util/OAuthParameterNames.java delete mode 100644 src/main/java/com/moabam/global/common/util/TokenConstant.java create mode 100644 src/main/java/com/moabam/global/config/TokenConfig.java create mode 100644 src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java create mode 100644 src/test/java/com/moabam/api/application/JwtProviderServiceTest.java create mode 100644 src/test/java/com/moabam/api/application/MemberServiceTest.java diff --git a/build.gradle b/build.gradle index e98fa317..9175fd97 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,14 @@ dependencies { // 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' diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index 02d93d78..c53b10f1 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -1,9 +1,8 @@ package com.moabam.api.application; -import static com.moabam.global.common.util.OAuthParameterNames.*; - 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; @@ -13,9 +12,10 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.common.constant.GlobalConstant; -import com.moabam.global.common.util.TokenConstant; +import com.moabam.global.common.util.CookieUtils; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -29,6 +29,8 @@ public class AuthenticationService { private final OAuthConfig oAuthConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; + private final MemberService memberService; + private final JwtProviderService jwtProviderService; public void redirectToLoginPage(HttpServletResponse httpServletResponse) { String authorizationCodeUri = getAuthorizationCodeUri(); @@ -37,36 +39,46 @@ public void redirectToLoginPage(HttpServletResponse httpServletResponse) { public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authorizationCodeResponse) { validAuthorizationGrant(authorizationCodeResponse.code()); + return issueTokenToAuthorizationServer(authorizationCodeResponse.code()); } public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); - ResponseEntity authorizationTokenInfoResponse - = oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + ResponseEntity authorizationTokenInfoResponse = + oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); return authorizationTokenInfoResponse.getBody(); } + @Transactional + public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, + AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + LoginResponse loginResponse = memberService.login(authorizationTokenInfoResponse); + issueServiceToken(httpServletResponse, loginResponse.id()); + + return loginResponse; + } + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } private String generateTokenValue(String token) { - return TokenConstant.TOKEN_TYPE + GlobalConstant.SPACE + 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()); + UriComponentsBuilder authorizationCodeUri = UriComponentsBuilder.fromUriString( + oAuthConfig.provider().authorizationUri()) + .queryParam("response_type", "code") + .queryParam("client_id", authorizationCodeRequest.clientId()) + .queryParam("redirect_uri", authorizationCodeRequest.redirectUri()); if (!authorizationCodeRequest.scope().isEmpty()) { - String scopes = String.join(GlobalConstant.COMMA, authorizationCodeRequest.scope()); - authorizationCodeUri.queryParam(SCOPE, scopes); + String scopes = String.join(",", authorizationCodeRequest.scope()); + authorizationCodeUri.queryParam("scope", scopes); } return authorizationCodeUri.toUriString(); @@ -91,15 +103,21 @@ private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code) 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()); + 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()); + contents.add("client_secret", authorizationTokenRequest.clientSecret()); } return contents; } + + private void issueServiceToken(HttpServletResponse response, Long id) { + response.addHeader("token_type", "Bearer"); + response.addCookie(CookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(id))); + response.addCookie(CookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(id))); + } } diff --git a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java new file mode 100644 index 00000000..14247dab --- /dev/null +++ b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java @@ -0,0 +1,43 @@ +package com.moabam.api.application; + +import java.util.Base64; + +import org.json.JSONObject; +import org.springframework.stereotype.Service; + +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 lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtAuthenticationService { + + private final TokenConfig tokenConfig; + + public boolean isTokenValid(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(token); + return true; + } catch (ExpiredJwtException expiredJwtException) { + return false; + } catch (Exception exception) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATIE_FAIL); + } + } + + public String parseEmail(String token) { + String claims = token.split("\\.")[1]; + String decodeClaims = new String(Base64.getDecoder().decode(claims)); + JSONObject jsonObject = new JSONObject(decodeClaims); + + return (String)jsonObject.get("id"); + } +} diff --git a/src/main/java/com/moabam/api/application/JwtProviderService.java b/src/main/java/com/moabam/api/application/JwtProviderService.java new file mode 100644 index 00000000..50466841 --- /dev/null +++ b/src/main/java/com/moabam/api/application/JwtProviderService.java @@ -0,0 +1,41 @@ +package com.moabam.api.application; + +import java.util.Date; + +import org.springframework.stereotype.Service; + +import com.moabam.global.config.TokenConfig; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class JwtProviderService { + + private final TokenConfig tokenConfig; + + public String provideAccessToken(long id) { + return generateToken(id, tokenConfig.getAccessExpire()); + } + + public String provideRefreshToken(long id) { + return generateToken(id, tokenConfig.getRefreshExpire()); + } + + private String generateToken(long id, long expireTime) { + 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) + .claim("id", id) + .signWith(tokenConfig.getKey(), SignatureAlgorithm.HS256) + .compact(); + } +} diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/MemberService.java index f2d32c7d..f3609251 100644 --- a/src/main/java/com/moabam/api/application/MemberService.java +++ b/src/main/java/com/moabam/api/application/MemberService.java @@ -2,14 +2,21 @@ import static com.moabam.global.error.model.ErrorMessage.*; +import java.security.SecureRandom; import java.util.List; +import java.util.Optional; +import org.apache.commons.lang3.RandomStringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.MemberSearchRepository; +import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.LoginResponse; +import com.moabam.api.dto.MemberMapper; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -21,12 +28,33 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberSearchRepository memberSearchRepository; + private final NotificationRepository notificationRepository; public Member getById(Long memberId) { return memberRepository.findById(memberId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } + @Transactional + public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + Optional member = memberRepository.findBySocialId(authorizationTokenInfoResponse.id()); + Member loginMember = member.orElse(signUp(authorizationTokenInfoResponse.id())); + + return MemberMapper.toLoginResponse(loginMember.getId(), member.isEmpty()); + } + + private Member signUp(Long socialId) { + String randomNickName = createRandomNickName(); + Member member = MemberMapper.toMember(socialId, randomNickName); + + return memberRepository.save(member); + } + + private String createRandomNickName() { + return RandomStringUtils.random(6, 0, 0, true, true, null, + new SecureRandom()); + } + public Member getManager(Long roomId) { return memberSearchRepository.findManager(roomId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index c054add0..66076649 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -15,7 +15,6 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.constant.GlobalConstant; -import com.moabam.global.common.util.TokenConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.handler.RestTemplateResponseHandler; import com.moabam.global.error.model.ErrorMessage; @@ -54,7 +53,7 @@ public ResponseEntity requestAuthorizationServer(Str public ResponseEntity tokenInfoRequest(String tokenInfoUri, String tokenValue) { HttpHeaders headers = new HttpHeaders(); - headers.add(TokenConstant.AUTHORIZATION, tokenValue); + headers.add("Authorization", tokenValue); HttpEntity httpEntity = new HttpEntity<>(headers); return restTemplate.exchange(tokenInfoUri, HttpMethod.GET, httpEntity, AuthorizationTokenInfoResponse.class); diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index a5c535c3..43427908 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -40,7 +40,7 @@ public class Member extends BaseTimeEntity { private Long id; @Column(name = "social_id", nullable = false, unique = true) - private String socialId; + private Long socialId; @Column(name = "nickname", nullable = false, unique = true) private String nickname; @@ -79,11 +79,11 @@ public class Member extends BaseTimeEntity { private LocalDateTime deletedAt; @Builder - private Member(Long id, String socialId, String nickname, String profileImage, Bug bug) { + private Member(Long id, Long socialId, String nickname, Bug bug) { this.id = id; - this.socialId = requireNonNull(socialId); + this.socialId = socialId; this.nickname = requireNonNull(nickname); - this.profileImage = requireNonNullElse(profileImage, BaseImageUrl.PROFILE_URL); + this.profileImage = BaseImageUrl.PROFILE_URL; this.bug = requireNonNull(bug); this.role = Role.USER; } diff --git a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java index 095c71b4..94bd696b 100644 --- a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/MemberRepository.java @@ -1,9 +1,12 @@ package com.moabam.api.domain.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.moabam.api.domain.entity.Member; public interface MemberRepository extends JpaRepository { + Optional findBySocialId(Long id); } diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java index 130a6d7b..b280c8be 100644 --- a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java @@ -18,7 +18,7 @@ public class NotificationRepository { private final StringRedisRepository stringRedisRepository; - // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. + // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. Front와 상의 후 삭제예정 public void saveFcmToken(Long key, String value) { stringRedisRepository.save( String.valueOf(requireNonNull(key)), diff --git a/src/main/java/com/moabam/api/dto/LoginResponse.java b/src/main/java/com/moabam/api/dto/LoginResponse.java new file mode 100644 index 00000000..49e13cc9 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/LoginResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record LoginResponse( + Long id, + boolean isSignUp +) { + +} diff --git a/src/main/java/com/moabam/api/dto/MemberMapper.java b/src/main/java/com/moabam/api/dto/MemberMapper.java new file mode 100644 index 00000000..2802b2ba --- /dev/null +++ b/src/main/java/com/moabam/api/dto/MemberMapper.java @@ -0,0 +1,32 @@ +package com.moabam.api.dto; + +import com.moabam.api.domain.entity.Bug; +import com.moabam.api.domain.entity.Member; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class MemberMapper { + + public static Member toMember(Long socialId, String nickName) { + return Member.builder() + .socialId(socialId) + .nickname(nickName) + .bug(Bug.builder().build()) + .build(); + } + + public static LoginResponse toLoginResponse(Long memberId) { + return LoginResponse.builder() + .id(memberId) + .build(); + } + + public static LoginResponse toLoginResponse(Long memberId, boolean isSignUp) { + return LoginResponse.builder() + .id(memberId) + .isSignUp(isSignUp) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 0554173e..20ec8018 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -1,13 +1,17 @@ package com.moabam.api.presentation; +import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.ModelAttribute; 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.AuthenticationService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -25,8 +29,13 @@ public void socialLogin(HttpServletResponse httpServletResponse) { } @GetMapping("/login/kakao/oauth") - public void authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse) { + @ResponseStatus(HttpStatus.OK) + public LoginResponse authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse, + HttpServletResponse httpServletResponse) { AuthorizationTokenResponse tokenResponse = authenticationService.requestToken(authorizationCodeResponse); - authenticationService.requestTokenInfo(tokenResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + authenticationService.requestTokenInfo(tokenResponse); + + return authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); } } diff --git a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java index 8a78c94f..436df263 100644 --- a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/constant/GlobalConstant.java @@ -7,8 +7,6 @@ public class GlobalConstant { public static final String BLANK = ""; - public static final String COMMA = ","; - public static final String UNDER_BAR = "_"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; } diff --git a/src/main/java/com/moabam/global/common/constant/RedisConstant.java b/src/main/java/com/moabam/global/common/constant/RedisConstant.java deleted file mode 100644 index fe06b7dd..00000000 --- a/src/main/java/com/moabam/global/common/constant/RedisConstant.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.moabam.global.common.constant; - -public class RedisConstant { - - public static final String REDIS_SERVER_MAX_MEMORY = "maxmemory 128M"; - public static final String REDIS_BINARY_PATH = "binary/redis/redis-server-arm64"; - public static final String FIND_LISTEN_PROCESS_COMMAND = "netstat -nat | grep LISTEN | grep %d"; - public static final String SHELL_PATH = "/bin/sh"; - public static final String SHELL_COMMAND_OPTION = "-c"; - public static final String OS_ARCHITECTURE = "os.arch"; - public static final String OS_NAME = "os.name"; - public static final String ARM_ARCHITECTURE = "aarch64"; - public static final String MAC_OS_NAME = "Mac OS X"; -} 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..b7ece395 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -0,0 +1,18 @@ +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) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + + return cookie; + } +} diff --git a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java b/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java deleted file mode 100644 index 05e26dea..00000000 --- a/src/main/java/com/moabam/global/common/util/OAuthParameterNames.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.moabam.global.common.util; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class OAuthParameterNames { - - public static final String RESPONSE_TYPE = "response_type"; - public static final String CODE = "code"; - public static final String CLIENT_ID = "client_id"; - public static final String REDIRECT_URI = "redirect_uri"; - public static final String SCOPE = "scope"; - public static final String GRANT_TYPE = "grant_type"; - public static final String CLIENT_SECRET = "client_secret"; -} diff --git a/src/main/java/com/moabam/global/common/util/TokenConstant.java b/src/main/java/com/moabam/global/common/util/TokenConstant.java deleted file mode 100644 index 0da12b30..00000000 --- a/src/main/java/com/moabam/global/common/util/TokenConstant.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.moabam.global.common.util; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public class TokenConstant { - - public static final String TOKEN_TYPE = "Bearer"; - public static final String ACCESS_TOKEN = "access_token"; - public static final String REFRESH_TOKEN = "refresh_token"; - public static final String AUTHORIZATION = "Authorization"; -} diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 16587cac..77a7fcbd 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -1,24 +1,26 @@ package com.moabam.global.config; -import static com.moabam.global.common.constant.RedisConstant.*; - import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStreamReader; -import java.util.Objects; 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.io.ClassPathResource; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.util.StringUtils; import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.model.ErrorMessage; -import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; +import lombok.Builder; import lombok.extern.slf4j.Slf4j; import redis.embedded.RedisServer; @@ -27,21 +29,45 @@ @Profile("test") public class EmbeddedRedisConfig { - @Value("${spring.data.redis.port}") - private int redisPort; + private final int redisPort; + private final String redisHost; + private int availablePort; private RedisServer redisServer; - @PostConstruct + 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 StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { + StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); + stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); + stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); + stringRedisTemplate.setConnectionFactory(redisConnectionFactory); + + return stringRedisTemplate; + } + public void startRedis() { - int port = isRedisRunning() ? findAvailablePort() : redisPort; + Os os = Os.createOs(); + availablePort = findPort(os); - if (isArmMac()) { - redisServer = new RedisServer(getRedisFileForArcMac(), port); + if (os.isMac()) { + redisServer = new RedisServer(getRedisFileForArcMac(), availablePort); } else { redisServer = RedisServer.builder() - .port(port) - .setting(REDIS_SERVER_MAX_MEMORY) + .port(availablePort) + .setting("maxmemory 128M") .build(); } @@ -64,9 +90,21 @@ public void stopRedis() { } } - public int findAvailablePort() { + 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 = executeGrepProcessCommand(port); + Process process = os.executeCommand(port); if (!isRunning(process)) { return port; @@ -76,21 +114,6 @@ public int findAvailablePort() { throw new MoabamException(ErrorMessage.NOT_FOUND_AVAILABLE_PORT); } - private boolean isRedisRunning() { - return isRunning(executeGrepProcessCommand(redisPort)); - } - - private Process executeGrepProcessCommand(int redisPort) { - String command = String.format(FIND_LISTEN_PROCESS_COMMAND, redisPort); - String[] shell = {SHELL_PATH, SHELL_COMMAND_OPTION, command}; - - try { - return Runtime.getRuntime().exec(shell); - } catch (IOException e) { - throw new MoabamException(e.getMessage()); - } - } - private boolean isRunning(Process process) { String line; StringBuilder pidInfo = new StringBuilder(); @@ -106,16 +129,83 @@ private boolean isRunning(Process process) { return StringUtils.hasText(pidInfo.toString()); } - private boolean isArmMac() { - return Objects.equals(System.getProperty(OS_ARCHITECTURE), ARM_ARCHITECTURE) - && Objects.equals(System.getProperty(OS_NAME), MAC_OS_NAME); - } - private File getRedisFileForArcMac() { try { - return new ClassPathResource(REDIS_BINARY_PATH).getFile(); + 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/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index d4d484c2..79c0e85b 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -3,12 +3,14 @@ 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.StringRedisTemplate; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration +@Profile("!test") public class RedisConfig { @Value("${spring.data.redis.host}") 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..fe6bdc91 --- /dev/null +++ b/src/main/java/com/moabam/global/config/TokenConfig.java @@ -0,0 +1,28 @@ +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 Key key; + + public TokenConfig(String iss, long accessExpire, long refreshExpire, String secretKey) { + this.iss = iss; + this.accessExpire = accessExpire; + this.refreshExpire = refreshExpire; + this.secretKey = secretKey; + this.key = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 6205b4ae..a5ee589a 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -26,6 +26,7 @@ public enum ErrorMessage { LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), GRANT_FAILED("인가 코드 실패"), + AUTHENTICATIE_FAIL("인증 실패"), MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), diff --git a/src/main/resources/config b/src/main/resources/config index 7026a658..8ba1e5fb 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7026a65853d700a4f25a700fd327e926b562eabf +Subproject commit 8ba1e5fbd724fc621b1f082c98356754244ad355 diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index c1cb84c5..06e3fd55 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index e28c38d8..1c3d4e18 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -473,7 +473,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 64 +Content-Length: 66 { "message" : "이미 콕 알림을 보낸 대상입니다." @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java index deb900f6..57afb71a 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java @@ -12,6 +12,8 @@ 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; @@ -25,12 +27,15 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.OAuthMapper; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.AuthorizationResponseFixture; +import jakarta.servlet.http.Cookie; + @ExtendWith(MockitoExtension.class) class AuthenticationServiceTest { @@ -40,6 +45,12 @@ class AuthenticationServiceTest { @Mock OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + @Mock + MemberService memberService; + + @Mock + JwtProviderService jwtProviderService; + OAuthConfig oauthConfig; AuthenticationService noPropertyService; OAuthConfig noOAuthConfig; @@ -58,8 +69,8 @@ public void initParams() { new OAuthConfig.Provider(null, null, null, null), new OAuthConfig.Client(null, null, null, null, null) ); - noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService); - + noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService, + memberService, jwtProviderService); } @DisplayName("인가코드 URI 생성 매퍼 실패") @@ -169,11 +180,52 @@ void generate_token() { = AuthorizationResponseFixture.authorizationTokenInfoResponse(); // When - when(oAuth2AuthorizationServerRequestService.tokenInfoRequest(eq(oauthConfig.provider().tokenInfo()), + when(oAuth2AuthorizationServerRequestService.tokenInfoRequest( + any(String.class), eq("Bearer " + tokenResponse.accessToken()))) .thenReturn(new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); // Then assertThatNoException().isThrownBy(() -> authenticationService.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() + .id(1L) + .isSignUp(isSignUp) + .build(); + + willReturn(loginResponse).given(memberService).login(authorizationTokenInfoResponse); + + // when + LoginResponse result = + authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + + // then + assertThat(loginResponse).isEqualTo(result); + assertThat(httpServletResponse.getHeader("token_type")).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("/") + ); + } } diff --git a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java new file mode 100644 index 00000000..1c4df207 --- /dev/null +++ b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java @@ -0,0 +1,108 @@ +package com.moabam.api.application; + +import static org.assertj.core.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.global.config.TokenConfig; +import com.moabam.global.error.exception.UnauthorizedException; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; + +@ExtendWith(MockitoExtension.class) +class JwtAuthenticationServiceTest { + + String originIss = "PARK"; + String originSecretKey = "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); + jwtProviderService = new JwtProviderService(tokenConfig); + jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + } + + @DisplayName("토큰 인증 시간 만료 테스트") + @Test + void token_authentication_time_expire() { + // Given + TokenConfig tokenConfig = new TokenConfig(originIss, 0, 0, originSecretKey); + JwtAuthenticationService jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + String token = jwtProviderService.provideAccessToken(originId); + + // When + assertThatNoException().isThrownBy(() -> { + boolean result = jwtAuthenticationService.isTokenValid(token); + + // Then + assertThat(result).isFalse(); + }); + } + + @DisplayName("토큰의 payload 변조되어 인증 실패") + @Test + void token_authenticate_failBy_payload() { + // Given + String token = jwtProviderService.provideAccessToken(originId); + 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.isTokenValid(newToken)) + .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.isTokenValid(token)) + .isExactlyInstanceOf(UnauthorizedException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java new file mode 100644 index 00000000..e1d84262 --- /dev/null +++ b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java @@ -0,0 +1,125 @@ +package com.moabam.api.application; + +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 com.moabam.global.config.TokenConfig; + +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jwts; + +class JwtProviderServiceTest { + + String iss = "PARK"; + String secretKey = "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); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + 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("refresh 토큰 생성 성공") + @Test + void create_refresh_token_success() throws JSONException { + // given + long refreshExpire = 15000L; + + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String refreshToken = jwtProviderService.provideRefreshToken(id); + + 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); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + // 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); + JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + + // when + String accessToken = jwtProviderService.provideAccessToken(id); + + // then + assertThatThrownBy(() -> Jwts.parserBuilder() + .setSigningKey(tokenConfig.getKey()) + .build() + .parseClaimsJwt(accessToken) + ).isExactlyInstanceOf(ExpiredJwtException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/MemberServiceTest.java b/src/test/java/com/moabam/api/application/MemberServiceTest.java new file mode 100644 index 00000000..f5ce03a0 --- /dev/null +++ b/src/test/java/com/moabam/api/application/MemberServiceTest.java @@ -0,0 +1,70 @@ +package com.moabam.api.application; + +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.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.entity.Member; +import com.moabam.api.domain.repository.MemberRepository; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.LoginResponse; +import com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.MemberFixture; + +@ExtendWith(MockitoExtension.class) +class MemberServiceTest { + + @InjectMocks + MemberService memberService; + + @Mock + MemberRepository memberRepository; + + @DisplayName("회원 존재하고 로그인 성공") + @Test + void member_exist_and_login_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + Member member = MemberFixture.member(); + willReturn(Optional.of(member)) + .given(memberRepository).findBySocialId(authorizationTokenInfoResponse.id()); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(result.id()).isEqualTo(member.getId()); + assertThat(result.isSignUp()).isFalse(); + } + + @DisplayName("회원가입 성공") + @Test + void signUp_success() { + // given + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + willReturn(Optional.empty()) + .given(memberRepository).findBySocialId(authorizationTokenInfoResponse.id()); + + Member member = spy(MemberFixture.member()); + given(member.getId()).willReturn(1L); + willReturn(member) + .given(memberRepository).save(any(Member.class)); + + // when + LoginResponse result = memberService.login(authorizationTokenInfoResponse); + + // then + assertThat(authorizationTokenInfoResponse.id()).isEqualTo(result.id()); + assertThat(result.isSignUp()).isTrue(); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index ab0df828..01c89306 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -13,7 +13,7 @@ class MemberTest { - String socialId = "1"; + Long socialId = 1L; String nickname = "밥세공기"; String profileImage = "kakao/profile/url"; @@ -24,7 +24,6 @@ void create_member_success() { assertThatNoException().isThrownBy(() -> Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(profileImage) .bug(Bug.builder().build()) .build()); } @@ -37,7 +36,6 @@ void create_member_noImage_success() { Member member = Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(null) .bug(Bug.builder().build()) .build(); diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 2ecf1883..32cef2d0 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,11 +1,9 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.util.OAuthParameterNames.*; 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 org.junit.jupiter.api.BeforeAll; @@ -37,6 +35,7 @@ import com.moabam.api.application.AuthenticationService; import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.config.OAuthConfig; @@ -86,10 +85,10 @@ 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())) + .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 @@ -101,26 +100,25 @@ void authorization_code_request_success() throws Exception { .andExpect(MockMvcResultMatchers.redirectedUrl(uri)); } - @DisplayName("Authorization Server에 토큰 발급 요청") + @DisplayName("소셜 로그인 및 회원가입 요청 성공") @Test - void authorization_token_request_success() throws Exception { + 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()); + 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(); AuthorizationTokenResponse authorizationTokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); - String response = objectMapper.writeValueAsString(authorizationTokenResponse); - // When - doReturn(AuthorizationResponseFixture.authorizationTokenInfoResponse()) - .when(authenticationService).requestTokenInfo(authorizationTokenResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = + AuthorizationResponseFixture.authorizationTokenInfoResponse(); + String tokenInfoResponse = objectMapper.writeValueAsString(authorizationTokenInfoResponse); // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) @@ -129,10 +127,25 @@ void authorization_token_request_success() throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withSuccess(response, MediaType.APPLICATION_JSON)); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + 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(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) - .andExpect(status().isOk()) - .andDo(print()); + .andExpectAll( + status().isOk(), + MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON), + MockMvcResultMatchers.header().string("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 발급 실패") @@ -141,11 +154,11 @@ void authorization_token_request_success() throws Exception { 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()); + 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(); @@ -156,32 +169,11 @@ void authorization_token_request_fail(int code) throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isBadRequest()); } - @DisplayName("토큰 정보 조회 요청") - @Test - void token_info_request_success() throws Exception { - // given - AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); - - // When - doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) - .when(authenticationService).requestToken(authorizationCodeResponse); - - // expected - mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) - .andExpect(MockRestRequestMatchers.method(HttpMethod.GET)) - .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) - .andRespond(withStatus(HttpStatusCode.valueOf(200))); - - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") - .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) - .andExpect(status().isOk()); - } - @DisplayName("토큰 정보 요청 실패") @ParameterizedTest @ValueSource(ints = {400, 401}) @@ -199,7 +191,7 @@ void token_info_response_fail(int code) throws Exception { .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - ResultActions result = mockMvc.perform(get("/members/login/kakao/oauth") + mockMvc.perform(get("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 7914b949..7083f1c2 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -61,7 +61,7 @@ class NotificationControllerTest { @BeforeEach void setUp() { - target = memberRepository.save(MemberFixture.member("target123", "targetName")); + target = memberRepository.save(MemberFixture.member(123L, "targetName")); room = roomRepository.save(RoomFixture.room()); knockKey = String.format(KNOCK_KEY, room.getId(), 1, target.getId()); diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 8857073e..e8c2113d 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -525,9 +525,8 @@ void enter_room_wrong_password_fail() throws Exception { Member member = Member.builder() .id(1L) - .socialId("test123") + .socialId(1L) .nickname("nick") - .profileImage("testtests") .bug(BugFixture.bug()) .build(); @@ -771,16 +770,14 @@ void get_room_details_test() throws Exception { participant1.enableManager(); Member member2 = Member.builder() - .socialId("SOCIAL_2") + .socialId(2L) .nickname("NICKNAME_2") - .profileImage("PROFILE_IMAGE_2") .bug(BugFixture.bug()) .build(); Member member3 = Member.builder() - .socialId("SOCIAL_3") + .socialId(3L) .nickname("NICKNAME_3") - .profileImage("PROFILE_IMAGE_3") .bug(BugFixture.bug()) .build(); diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java index 80bb1b03..e1b4d000 100644 --- a/src/test/java/com/moabam/support/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -4,24 +4,21 @@ public final class MemberFixture { - public static final String SOCIAL_ID = "test123"; + public static final long SOCIAL_ID = 1L; public static final String NICKNAME = "모아밤"; - public static final String PROFILE_IMAGE = "/profile/moabam.png"; public static Member member() { return Member.builder() .socialId(SOCIAL_ID) .nickname(NICKNAME) - .profileImage(PROFILE_IMAGE) .bug(BugFixture.bug()) .build(); } - public static Member member(String socialId, String nickname) { + public static Member member(Long socialId, String nickname) { return Member.builder() .socialId(socialId) .nickname(nickname) - .profileImage(PROFILE_IMAGE) .bug(BugFixture.bug()) .build(); } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3347b41f..723cc57c 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -33,5 +33,10 @@ oauth2: authorization_uri: https://authorization.com/test/test redirect_uri: http://redirect:8080/test token_uri: https://token.com/test/test - token-info: https://api.token.com/test + token_info: https://api.token.com/test +token: + iss: "PARK" + access-expire: 100000 + refresh-expire: 150000 + secret-key: testestestestestestestestestesttestestestestestestestestestest From 35bac08376efad62b3139a71dddbcb0334088d5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Tue, 7 Nov 2023 23:59:30 +0900 Subject: [PATCH 024/185] =?UTF-8?q?feat:=20=EC=9D=B8=EC=A6=9D=20=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#50)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 인증 타임에 따른 주기적 알림 기능 도입 * test: 인증타임에 따른 주기적 알림 기능 테스트 * test: Restdoc 파일 * refactor: 코드 리뷰 반영 * refactor: 코드 리뷰 반영 * fix: checkstyle 수정 * refactor: 코드 리뷰 반영 * refactor: 리뷰 반영 --- .../application/AuthenticationService.java | 2 +- .../api/application/NotificationService.java | 38 ++++++++++-- ...uth2AuthorizationServerRequestService.java | 2 +- .../repository/NotificationRepository.java | 6 +- .../ParticipantSearchRepository.java | 8 +++ .../moabam/api/dto/NotificationMapper.java | 12 +++- .../global/common/constant/FcmConstant.java | 9 --- .../{constant => util}/GlobalConstant.java | 6 +- .../com/moabam/global/config/FcmConfig.java | 2 +- src/main/resources/config | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 4 +- .../application/NotificationServiceTest.java | 60 ++++++++++++++++++- ...AuthorizationServerRequestServiceTest.java | 2 +- .../ParticipantSearchRepositoryTest.java | 45 ++++++++++++++ .../presentation/MemberControllerTest.java | 2 +- .../NotificationControllerTest.java | 4 +- .../support/fixture/ParticipantFixture.java | 42 +++++++++++++ .../{ => support}/fixture/RoomFixture.java | 13 +++- 19 files changed, 227 insertions(+), 34 deletions(-) delete mode 100644 src/main/java/com/moabam/global/common/constant/FcmConstant.java rename src/main/java/com/moabam/global/common/{constant => util}/GlobalConstant.java (51%) create mode 100644 src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/support/fixture/ParticipantFixture.java rename src/test/java/com/moabam/{ => support}/fixture/RoomFixture.java (50%) diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthenticationService.java index c53b10f1..384d4caa 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthenticationService.java @@ -14,8 +14,8 @@ import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.OAuthMapper; -import com.moabam.global.common.constant.GlobalConstant; import com.moabam.global.common.util.CookieUtils; +import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/main/java/com/moabam/api/application/NotificationService.java b/src/main/java/com/moabam/api/application/NotificationService.java index 4bf18136..d70cd57a 100644 --- a/src/main/java/com/moabam/api/application/NotificationService.java +++ b/src/main/java/com/moabam/api/application/NotificationService.java @@ -1,14 +1,20 @@ package com.moabam.api.application; -import static com.moabam.global.common.constant.FcmConstant.*; +import static com.moabam.global.common.util.GlobalConstant.*; +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; +import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.api.dto.NotificationMapper; import com.moabam.global.common.annotation.MemberTest; import com.moabam.global.error.exception.ConflictException; @@ -22,21 +28,43 @@ @Transactional(readOnly = true) public class NotificationService { + private final RoomService roomService; private final FirebaseMessaging firebaseMessaging; private final NotificationRepository notificationRepository; + private final ParticipantSearchRepository participantSearchRepository; @Transactional public void sendKnockNotification(MemberTest member, Long targetId, Long roomId) { + roomService.validateRoomById(roomId); + String knockKey = generateKnockKey(member.memberId(), targetId, roomId); validateConflictKnockNotification(knockKey); validateFcmToken(targetId); - String fcmToken = notificationRepository.findFcmTokenByMemberId(targetId); Notification notification = NotificationMapper.toKnockNotificationEntity(member.nickname()); - Message message = NotificationMapper.toMessageEntity(notification, fcmToken); - + sendAsyncFcm(targetId, notification); notificationRepository.saveKnockNotification(knockKey); - firebaseMessaging.sendAsync(message); + } + + @Scheduled(cron = "0 50 * * * *") + public void sendCertificationTimeNotification() { + int certificationTime = (LocalDateTime.now().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; + List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime); + + participants.parallelStream().forEach(participant -> { + String roomTitle = participant.getRoom().getTitle(); + Notification notification = NotificationMapper.toCertifyAuthNotificationEntity(roomTitle); + sendAsyncFcm(participant.getMemberId(), notification); + }); + } + + private void sendAsyncFcm(Long fcmTokenKey, Notification notification) { + String fcmToken = notificationRepository.findFcmTokenByMemberId(fcmTokenKey); + + if (fcmToken != null) { + Message message = NotificationMapper.toMessageEntity(notification, fcmToken); + firebaseMessaging.sendAsync(message); + } } private void validateConflictKnockNotification(String knockKey) { diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java index 66076649..78c3c278 100644 --- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java +++ b/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java @@ -14,7 +14,7 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.global.common.constant.GlobalConstant; +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; diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java index b280c8be..f93f9ac2 100644 --- a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java @@ -1,7 +1,6 @@ package com.moabam.api.domain.repository; -import static com.moabam.global.common.constant.FcmConstant.*; -import static com.moabam.global.common.constant.GlobalConstant.*; +import static com.moabam.global.common.util.GlobalConstant.*; import static java.util.Objects.*; import java.time.Duration; @@ -16,6 +15,9 @@ @RequiredArgsConstructor public class NotificationRepository { + private static final long EXPIRE_KNOCK = 12; + private static final long EXPIRE_FCM_TOKEN = 60; + private final StringRedisRepository stringRedisRepository; // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. Front와 상의 후 삭제예정 diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java index 91830965..768e6946 100644 --- a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java @@ -41,4 +41,12 @@ public List findParticipants(Long roomId) { ) .fetch(); } + + public List findAllByRoomCertifyTime(int certifyTime) { + return jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where(participant.room.certifyTime.eq(certifyTime)) + .fetch(); + } } diff --git a/src/main/java/com/moabam/api/dto/NotificationMapper.java b/src/main/java/com/moabam/api/dto/NotificationMapper.java index 79795e15..b5283825 100644 --- a/src/main/java/com/moabam/api/dto/NotificationMapper.java +++ b/src/main/java/com/moabam/api/dto/NotificationMapper.java @@ -9,16 +9,24 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class NotificationMapper { - private static final String TITLE = "모아밤"; + private static final String NOTIFICATION_TITLE = "모아밤"; private static final String KNOCK_BODY = "님이 콕 찔렀습니다."; + private static final String CERTIFY_TIME_BODY = "방 인증 시간입니다."; public static Notification toKnockNotificationEntity(String nickname) { return Notification.builder() - .setTitle(TITLE) + .setTitle(NOTIFICATION_TITLE) .setBody(nickname + KNOCK_BODY) .build(); } + public static Notification toCertifyAuthNotificationEntity(String title) { + return Notification.builder() + .setTitle(NOTIFICATION_TITLE) + .setBody(title + CERTIFY_TIME_BODY) + .build(); + } + public static Message toMessageEntity(Notification notification, String fcmToken) { return Message.builder() .setNotification(notification) diff --git a/src/main/java/com/moabam/global/common/constant/FcmConstant.java b/src/main/java/com/moabam/global/common/constant/FcmConstant.java deleted file mode 100644 index 7a0cf6d2..00000000 --- a/src/main/java/com/moabam/global/common/constant/FcmConstant.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.moabam.global.common.constant; - -public class FcmConstant { - - public static final long EXPIRE_KNOCK = 12; - public static final long EXPIRE_FCM_TOKEN = 60; - public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; - public static final String FIREBASE_PATH = "config/moabam-firebase.json"; -} diff --git a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java similarity index 51% rename from src/main/java/com/moabam/global/common/constant/GlobalConstant.java rename to src/main/java/com/moabam/global/common/util/GlobalConstant.java index 436df263..f1714cc1 100644 --- a/src/main/java/com/moabam/global/common/constant/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -1,4 +1,4 @@ -package com.moabam.global.common.constant; +package com.moabam.global.common.util; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -9,4 +9,8 @@ public class GlobalConstant { public static final String BLANK = ""; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; + public static final int ONE_HOUR = 1; + public static final int HOURS_IN_A_DAY = 24; + public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + public static final String FIREBASE_PATH = "config/moabam-firebase.json"; } diff --git a/src/main/java/com/moabam/global/config/FcmConfig.java b/src/main/java/com/moabam/global/config/FcmConfig.java index 0c5a7cbc..678248b5 100644 --- a/src/main/java/com/moabam/global/config/FcmConfig.java +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -1,6 +1,6 @@ package com.moabam.global.config; -import static com.moabam.global.common.constant.FcmConstant.*; +import static com.moabam.global.common.util.GlobalConstant.*; import java.io.IOException; import java.io.InputStream; diff --git a/src/main/resources/config b/src/main/resources/config index 8ba1e5fb..7026a658 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 8ba1e5fbd724fc621b1f082c98356754244ad355 +Subproject commit 7026a65853d700a4f25a700fd327e926b562eabf diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 06e3fd55..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 1c3d4e18..b644496c 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -473,7 +473,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "이미 콕 알림을 보낸 대상입니다." @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index d733cd5b..ec72a657 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -3,17 +3,23 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.util.List; + import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.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.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; +import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.repository.NotificationRepository; +import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.global.common.annotation.MemberTest; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -26,11 +32,17 @@ class NotificationServiceTest { private NotificationService notificationService; @Mock - private NotificationRepository notificationRepository; + private RoomService roomService; @Mock private FirebaseMessaging firebaseMessaging; + @Mock + private NotificationRepository notificationRepository; + + @Mock + private ParticipantSearchRepository participantSearchRepository; + private MemberTest memberTest; @BeforeEach @@ -42,6 +54,7 @@ void setUp() { @Test void notificationService_sendKnockNotification() { // Given + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); @@ -54,10 +67,22 @@ void notificationService_sendKnockNotification() { verify(notificationRepository).saveKnockNotification(any(String.class)); } + @DisplayName("콕 찌를 상대의 방이 존재하지 않을 때, - NotFoundException") + @Test + void notificationService_sendKnockNotification_Room_NotFoundException() { + // Given + willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); + + // When & Then + assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + .isInstanceOf(NotFoundException.class); + } + @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않을 때, - NotFoundException") @Test - void notificationService_sendKnockNotification_NotFoundException() { + void notificationService_sendKnockNotification_FcmToken_NotFoundException() { // Given + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(false); @@ -71,6 +96,7 @@ void notificationService_sendKnockNotification_NotFoundException() { @Test void notificationService_sendKnockNotification_ConflictException() { // Given + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsByKey(any(String.class))).willReturn(true); // When & Then @@ -78,4 +104,34 @@ void notificationService_sendKnockNotification_ConflictException() { .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } + + @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낼 때, - Void") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void notificationService_sendCertificationTimeNotification(List participants) { + // Given + given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); + given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + + // When + notificationService.sendCertificationTimeNotification(); + + // Then + verify(firebaseMessaging, times(3)).sendAsync(any(Message.class)); + } + + @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없을 때, - Void") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(List participants) { + // Given + given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); + given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn(null); + + // When + notificationService.sendCertificationTimeNotification(); + + // Then + verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); + } } diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java index fc011ffa..c54b93f5 100644 --- a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -31,7 +31,7 @@ import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.global.common.constant.GlobalConstant; +import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java new file mode 100644 index 00000000..5ac95f4a --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java @@ -0,0 +1,45 @@ +package com.moabam.api.domain.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.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.moabam.api.domain.entity.Participant; +import com.moabam.api.domain.entity.Room; +import com.moabam.global.config.JpaConfig; + +@DataJpaTest +@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(3); + } +} diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 32cef2d0..1c92c1a5 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -37,7 +37,7 @@ import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.global.common.constant.GlobalConstant; +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; diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 7083f1c2..3edbab12 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -1,6 +1,6 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.constant.FcmConstant.*; +import static com.moabam.global.common.util.GlobalConstant.*; import static org.mockito.BDDMockito.*; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; @@ -27,9 +27,9 @@ import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.domain.repository.RoomRepository; -import com.moabam.fixture.RoomFixture; import com.moabam.global.common.repository.StringRedisRepository; import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; @Transactional @SpringBootTest 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..c4b2c5c3 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java @@ -0,0 +1,42 @@ +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.entity.Participant; +import com.moabam.api.domain.entity.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, 2L), + ParticipantFixture.participant(room, 3L) + ))); + } + + 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) + )) + ); + } +} diff --git a/src/test/java/com/moabam/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java similarity index 50% rename from src/test/java/com/moabam/fixture/RoomFixture.java rename to src/test/java/com/moabam/support/fixture/RoomFixture.java index 7a7af4a3..b65518b4 100644 --- a/src/test/java/com/moabam/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -1,9 +1,9 @@ -package com.moabam.fixture; +package com.moabam.support.fixture; import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.enums.RoomType; -public class RoomFixture { +public final class RoomFixture { public static Room room() { return Room.builder() @@ -13,4 +13,13 @@ public static Room room() { .maxUserCount(8) .build(); } + + public static Room room(int certifyTime) { + return Room.builder() + .title("testTitle") + .roomType(RoomType.MORNING) + .certifyTime(certifyTime) + .maxUserCount(8) + .build(); + } } From c40b8bbbb299b0f8bd26bc1459ef309c75b3bb8d Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Wed, 8 Nov 2023 14:31:10 +0900 Subject: [PATCH 025/185] chore: config update (#51) --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 7026a658..f392e58a 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 7026a65853d700a4f25a700fd327e926b562eabf +Subproject commit f392e58aefb231e765995b30c8c0194a67756b8c From 51ccfaa0fddb7a6c6ad3c24b42f5f3e7fb044e93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Wed, 8 Nov 2023 14:58:12 +0900 Subject: [PATCH 026/185] =?UTF-8?q?feat:=20=EC=BD=95=20=EC=B0=8C=EB=A5=B4?= =?UTF-8?q?=EA=B8=B0=20=EC=97=AC=EB=B6=80=EB=A5=BC=20=ED=99=95=EC=9D=B8?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 콕 찌르기 여부를 확인하는 기능 구현 * test: 콕 찌르기 여부를 확인하는 기능 테스트 * test: 콕 찌르기 여부를 확인하는 기능 테스트 --- .../api/application/NotificationService.java | 22 +++++++++++ .../ParticipantSearchRepository.java | 10 +++++ .../dto/KnockNotificationStatusResponse.java | 13 +++++++ .../moabam/api/dto/NotificationMapper.java | 12 ++++++ .../application/NotificationServiceTest.java | 37 +++++++++++++++++++ .../ParticipantSearchRepositoryTest.java | 17 ++++++++- .../support/fixture/ParticipantFixture.java | 8 ++-- 7 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java diff --git a/src/main/java/com/moabam/api/application/NotificationService.java b/src/main/java/com/moabam/api/application/NotificationService.java index d70cd57a..12c3ddf9 100644 --- a/src/main/java/com/moabam/api/application/NotificationService.java +++ b/src/main/java/com/moabam/api/application/NotificationService.java @@ -4,6 +4,9 @@ import java.time.LocalDateTime; 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; @@ -15,6 +18,7 @@ import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; +import com.moabam.api.dto.KnockNotificationStatusResponse; import com.moabam.api.dto.NotificationMapper; import com.moabam.global.common.annotation.MemberTest; import com.moabam.global.error.exception.ConflictException; @@ -58,6 +62,24 @@ public void sendCertificationTimeNotification() { }); } + /** + * TODO : 영명-재윤님 방 조회하실 때, 특정 사용자의 방 내 참여자들에 대한 콕 찌르기 여부를 반환해주는 메서드이니 사용하시기 바랍니다. + */ + public KnockNotificationStatusResponse checkMyKnockNotificationStatusInRoom(MemberTest member, Long roomId) { + List participants = participantSearchRepository.findOtherParticipantsInRoom(member.memberId(), + roomId); + + Predicate knockPredicate = targetId -> + notificationRepository.existsByKey(generateKnockKey(member.memberId(), targetId, roomId)); + + Map> knockNotificationStatus = participants.stream() + .map(Participant::getMemberId) + .collect(Collectors.partitioningBy(knockPredicate)); + + return NotificationMapper + .toKnockNotificationStatusResponse(knockNotificationStatus.get(true), knockNotificationStatus.get(false)); + } + private void sendAsyncFcm(Long fcmTokenKey, Notification notification) { String fcmToken = notificationRepository.findFcmTokenByMemberId(fcmTokenKey); diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java index 768e6946..bff41c2d 100644 --- a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java @@ -42,6 +42,16 @@ public List findParticipants(Long roomId) { .fetch(); } + public List findOtherParticipantsInRoom(Long memberId, Long roomId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId), + participant.memberId.ne(memberId) + ) + .fetch(); + } + public List findAllByRoomCertifyTime(int certifyTime) { return jpaQueryFactory .selectFrom(participant) diff --git a/src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java b/src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java new file mode 100644 index 00000000..fe500e5c --- /dev/null +++ b/src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record KnockNotificationStatusResponse( + List knockedMembersId, + List notKnockedMembersId +) { + +} diff --git a/src/main/java/com/moabam/api/dto/NotificationMapper.java b/src/main/java/com/moabam/api/dto/NotificationMapper.java index b5283825..e3c2b477 100644 --- a/src/main/java/com/moabam/api/dto/NotificationMapper.java +++ b/src/main/java/com/moabam/api/dto/NotificationMapper.java @@ -1,5 +1,7 @@ package com.moabam.api.dto; +import java.util.List; + import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; @@ -33,4 +35,14 @@ public static Message toMessageEntity(Notification notification, String fcmToken .setToken(fcmToken) .build(); } + + public static KnockNotificationStatusResponse toKnockNotificationStatusResponse( + List knockedMembersId, + List notKnockedMembersId + ) { + return KnockNotificationStatusResponse.builder() + .knockedMembersId(knockedMembersId) + .notKnockedMembersId(notKnockedMembersId) + .build(); + } } diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index ec72a657..f06482a4 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -20,6 +20,7 @@ import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; +import com.moabam.api.dto.KnockNotificationStatusResponse; import com.moabam.global.common.annotation.MemberTest; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -134,4 +135,40 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L // Then verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); } + + @DisplayName("특정 방에서 나 이외의 모든 사용자를 콕 찔렀을 때, - KnockNotificationStatusResponse") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { + // Given + given(participantSearchRepository.findOtherParticipantsInRoom(any(Long.class), any(Long.class))) + .willReturn(participants); + given(notificationRepository.existsByKey(any(String.class))).willReturn(true); + + // When + KnockNotificationStatusResponse actual = + notificationService.checkMyKnockNotificationStatusInRoom(memberTest, 1L); + + // Then + assertThat(actual.knockedMembersId()).hasSize(3); + assertThat(actual.notKnockedMembersId()).isEmpty(); + } + + @DisplayName("특정 방에서 나 이외의 모든 사용자를 콕 안 찔렀을 때, - KnockNotificationStatusResponse") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") + @ParameterizedTest + void notificationService_notKnocked_checkMyKnockNotificationStatusInRoom(List participants) { + // Given + given(participantSearchRepository.findOtherParticipantsInRoom(any(Long.class), any(Long.class))) + .willReturn(participants); + given(notificationRepository.existsByKey(any(String.class))).willReturn(false); + + // When + KnockNotificationStatusResponse actual = + notificationService.checkMyKnockNotificationStatusInRoom(memberTest, 1L); + + // Then + assertThat(actual.knockedMembersId()).isEmpty(); + assertThat(actual.notKnockedMembersId()).hasSize(3); + } } diff --git a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java index 5ac95f4a..4039d2c5 100644 --- a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java @@ -40,6 +40,21 @@ void participantSearchRepository_findAllByRoomCertifyTime(Room room, List actual = participantSearchRepository.findAllByRoomCertifyTime(10); // Then - assertThat(actual).hasSize(3); + assertThat(actual).hasSize(5); + } + + @DisplayName("특정 방에서 본인을 제외한 참여자 조회를 성공적으로 했을 때, - List") + @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideRoomAndParticipants") + @ParameterizedTest + void participantSearchRepository_findOtherParticipantsInRoom(Room room, List participants) { + // Given + roomRepository.save(room); + participantRepository.saveAll(participants); + + // When + List actual = participantSearchRepository.findOtherParticipantsInRoom(7L, room.getId()); + + // Then + assertThat(actual).hasSize(4); } } diff --git a/src/test/java/com/moabam/support/fixture/ParticipantFixture.java b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java index c4b2c5c3..08bdcbeb 100644 --- a/src/test/java/com/moabam/support/fixture/ParticipantFixture.java +++ b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java @@ -22,8 +22,8 @@ public static Stream provideParticipants() { return Stream.of(Arguments.of(List.of( ParticipantFixture.participant(room, 1L), - ParticipantFixture.participant(room, 2L), - ParticipantFixture.participant(room, 3L) + ParticipantFixture.participant(room, 3L), + ParticipantFixture.participant(room, 7L) ))); } @@ -35,7 +35,9 @@ public static Stream provideRoomAndParticipants() { List.of( ParticipantFixture.participant(room, 1L), ParticipantFixture.participant(room, 2L), - ParticipantFixture.participant(room, 3L) + ParticipantFixture.participant(room, 3L), + ParticipantFixture.participant(room, 5L), + ParticipantFixture.participant(room, 7L) )) ); } From 0a25dd409aaad95295350fb46072ed3bd99a1454 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 9 Nov 2023 19:06:57 +0900 Subject: [PATCH 027/185] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 벌레 내역 관련 Entity 생성 * feat: 아이템 구매 API 구현 * refactor: Bug -> Wallet 네이밍 수정 * refactor: Bug로 네이밍 재수정 * refactor: Entity 생성 로직 Mapper로 이동 * fix: isDefault nullable 하도록 수정 * fix: 레벨 1부터 시작하도록 수정 * test: 아이템 구매 Service 테스트 * test: 아이템 Entity 테스트 * test: 벌레 Entity 테스트 * test: 아이템 구매 Controller 테스트 * style: decrease로 메서드 네이밍 수정 * feat: 해당 벌레 타입의 개수 증가 메서드 추가 * chore: Table 어노테이션 추가 * test: 벌레 개수 증가 테스트 --- .../moabam/api/application/ItemService.java | 40 ++++++ .../com/moabam/api/domain/entity/Bug.java | 37 ++++++ .../moabam/api/domain/entity/BugHistory.java | 64 ++++++++++ .../com/moabam/api/domain/entity/Item.java | 22 ++++ .../com/moabam/api/domain/entity/Member.java | 5 + .../domain/entity/enums/BugActionType.java | 10 ++ .../api/domain/entity/enums/BugType.java | 12 ++ .../api/domain/entity/enums/ItemType.java | 16 ++- .../repository/BugHistoryRepository.java | 9 ++ .../java/com/moabam/api/dto/BugMapper.java | 12 ++ .../java/com/moabam/api/dto/ItemMapper.java | 8 ++ .../moabam/api/dto/PurchaseItemRequest.java | 11 ++ .../api/presentation/ItemController.java | 9 ++ .../global/common/util/GlobalConstant.java | 2 + .../global/error/model/ErrorMessage.java | 6 + src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../api/application/ItemServiceTest.java | 89 ++++++++++++- .../com/moabam/api/domain/entity/BugTest.java | 49 ++++++++ .../moabam/api/domain/entity/ItemTest.java | 117 ++++++++++++++++++ .../api/presentation/ItemControllerTest.java | 19 +++ 21 files changed, 534 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/entity/BugHistory.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/BugType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java create mode 100644 src/main/java/com/moabam/api/dto/PurchaseItemRequest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/ItemTest.java diff --git a/src/main/java/com/moabam/api/application/ItemService.java b/src/main/java/com/moabam/api/application/ItemService.java index 4ba1203e..66a8bb92 100644 --- a/src/main/java/com/moabam/api/application/ItemService.java +++ b/src/main/java/com/moabam/api/application/ItemService.java @@ -7,13 +7,21 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.domain.entity.Bug; import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.BugHistoryRepository; +import com.moabam.api.domain.repository.InventoryRepository; import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemRepository; import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.dto.BugMapper; import com.moabam.api.dto.ItemMapper; import com.moabam.api.dto.ItemsResponse; +import com.moabam.api.dto.PurchaseItemRequest; +import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -23,8 +31,12 @@ @RequiredArgsConstructor public class ItemService { + private final MemberService memberService; + private final ItemRepository itemRepository; private final ItemSearchRepository itemSearchRepository; + private final InventoryRepository inventoryRepository; private final InventorySearchRepository inventorySearchRepository; + private final BugHistoryRepository bugHistoryRepository; public ItemsResponse getItems(Long memberId, ItemType type) { List purchasedItems = inventorySearchRepository.findItems(memberId, type); @@ -33,6 +45,22 @@ public ItemsResponse getItems(Long memberId, ItemType type) { return ItemMapper.toItemsResponse(purchasedItems, notPurchasedItems); } + @Transactional + public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request) { + Item item = getItem(itemId); + Member member = memberService.getById(memberId); + + validateAlreadyPurchased(memberId, itemId); + item.validatePurchasable(request.bugType(), member.getLevel()); + + Bug bug = member.getBug(); + int price = item.getPrice(request.bugType()); + + bug.use(request.bugType(), price); + inventoryRepository.save(ItemMapper.toInventory(memberId, item)); + bugHistoryRepository.save(BugMapper.toUseBugHistory(memberId, request.bugType(), price)); + } + @Transactional public void selectItem(Long memberId, Long itemId) { Inventory inventory = getInventory(memberId, itemId); @@ -42,8 +70,20 @@ public void selectItem(Long memberId, Long itemId) { inventory.select(); } + 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 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/domain/entity/Bug.java b/src/main/java/com/moabam/api/domain/entity/Bug.java index 12fd020b..255febe7 100644 --- a/src/main/java/com/moabam/api/domain/entity/Bug.java +++ b/src/main/java/com/moabam/api/domain/entity/Bug.java @@ -4,6 +4,7 @@ import org.hibernate.annotations.ColumnDefault; +import com.moabam.api.domain.entity.enums.BugType; import com.moabam.global.error.exception.BadRequestException; import jakarta.persistence.Column; @@ -44,4 +45,40 @@ private int validateBugCount(int bug) { return bug; } + + public void use(BugType bugType, int price) { + int currentBug = getBug(bugType); + validateEnoughBug(currentBug, price); + decreaseBug(bugType, price); + } + + 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 price) { + if (price > currentBug) { + throw new BadRequestException(BUG_NOT_ENOUGH); + } + } + + private void decreaseBug(BugType bugType, int bug) { + switch (bugType) { + case MORNING -> this.morningBug -= bug; + case NIGHT -> this.nightBug -= bug; + case GOLDEN -> this.goldenBug -= bug; + } + } + + public void increaseBug(BugType bugType, int bug) { + switch (bugType) { + case MORNING -> this.morningBug += bug; + case NIGHT -> this.nightBug += bug; + case GOLDEN -> this.goldenBug += bug; + } + } } diff --git a/src/main/java/com/moabam/api/domain/entity/BugHistory.java b/src/main/java/com/moabam/api/domain/entity/BugHistory.java new file mode 100644 index 00000000..5c620e9f --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/BugHistory.java @@ -0,0 +1,64 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import com.moabam.api.domain.entity.enums.BugActionType; +import com.moabam.api.domain.entity.enums.BugType; +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 = "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; + + @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, BugType bugType, BugActionType actionType, int quantity) { + this.memberId = requireNonNull(memberId); + 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/entity/Item.java b/src/main/java/com/moabam/api/domain/entity/Item.java index defce5f4..ebe7512f 100644 --- a/src/main/java/com/moabam/api/domain/entity/Item.java +++ b/src/main/java/com/moabam/api/domain/entity/Item.java @@ -5,6 +5,7 @@ import org.hibernate.annotations.ColumnDefault; +import com.moabam.api.domain.entity.enums.BugType; import com.moabam.api.domain.entity.enums.ItemCategory; import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.global.common.entity.BaseTimeEntity; @@ -87,4 +88,25 @@ private int validateLevel(int 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; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index 43427908..fb866e07 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -1,5 +1,6 @@ package com.moabam.api.domain.entity; +import static com.moabam.global.common.util.GlobalConstant.*; import static java.util.Objects.*; import java.time.LocalDateTime; @@ -107,4 +108,8 @@ public void exitNightRoom() { currentNightCount--; } } + + public int getLevel() { + return (int)(totalCertifyCount / LEVEL_DIVISOR) + 1; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java b/src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java new file mode 100644 index 00000000..3b9018b0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java @@ -0,0 +1,10 @@ +package com.moabam.api.domain.entity.enums; + +public enum BugActionType { + + REWARD, + CHARGE, + USE, + REFUND, + COUPON; +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/BugType.java b/src/main/java/com/moabam/api/domain/entity/enums/BugType.java new file mode 100644 index 00000000..d7f3596a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/BugType.java @@ -0,0 +1,12 @@ +package com.moabam.api.domain.entity.enums; + +public enum BugType { + + MORNING, + NIGHT, + GOLDEN; + + public boolean isGoldenBug() { + return this == GOLDEN; + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java index f21fbc68..e2baefe4 100644 --- a/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java +++ b/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java @@ -1,7 +1,19 @@ package com.moabam.api.domain.entity.enums; +import java.util.List; + public enum ItemType { - MORNING, - NIGHT; + 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/repository/BugHistoryRepository.java b/src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java new file mode 100644 index 00000000..09c218c0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.BugHistory; + +public interface BugHistoryRepository extends JpaRepository { + +} diff --git a/src/main/java/com/moabam/api/dto/BugMapper.java b/src/main/java/com/moabam/api/dto/BugMapper.java index d596c6dd..1c86f990 100644 --- a/src/main/java/com/moabam/api/dto/BugMapper.java +++ b/src/main/java/com/moabam/api/dto/BugMapper.java @@ -1,6 +1,9 @@ package com.moabam.api.dto; import com.moabam.api.domain.entity.Bug; +import com.moabam.api.domain.entity.BugHistory; +import com.moabam.api.domain.entity.enums.BugActionType; +import com.moabam.api.domain.entity.enums.BugType; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -15,4 +18,13 @@ public static BugResponse toBugResponse(Bug bug) { .goldenBug(bug.getGoldenBug()) .build(); } + + public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int quantity) { + return BugHistory.builder() + .memberId(memberId) + .bugType(bugType) + .actionType(BugActionType.USE) + .quantity(quantity) + .build(); + } } diff --git a/src/main/java/com/moabam/api/dto/ItemMapper.java b/src/main/java/com/moabam/api/dto/ItemMapper.java index b468a5e4..5b2ffe5f 100644 --- a/src/main/java/com/moabam/api/dto/ItemMapper.java +++ b/src/main/java/com/moabam/api/dto/ItemMapper.java @@ -2,6 +2,7 @@ import java.util.List; +import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; import com.moabam.global.common.util.StreamUtils; @@ -30,4 +31,11 @@ public static ItemsResponse toItemsResponse(List purchasedItems, List
diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index b644496c..2dff8a58 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/ItemServiceTest.java index b07741a8..54eead85 100644 --- a/src/test/java/com/moabam/api/application/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/ItemServiceTest.java @@ -1,6 +1,8 @@ package com.moabam.api.application; +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.*; @@ -17,16 +19,23 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.domain.entity.BugHistory; import com.moabam.api.domain.entity.Inventory; import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.Member; +import com.moabam.api.domain.entity.enums.BugType; import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.repository.BugHistoryRepository; +import com.moabam.api.domain.repository.InventoryRepository; import com.moabam.api.domain.repository.InventorySearchRepository; +import com.moabam.api.domain.repository.ItemRepository; import com.moabam.api.domain.repository.ItemSearchRepository; import com.moabam.api.dto.ItemResponse; import com.moabam.api.dto.ItemsResponse; +import com.moabam.api.dto.PurchaseItemRequest; import com.moabam.global.common.util.StreamUtils; +import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; -import com.moabam.support.fixture.InventoryFixture; @ExtendWith(MockitoExtension.class) class ItemServiceTest { @@ -34,12 +43,24 @@ class ItemServiceTest { @InjectMocks ItemService itemService; + @Mock + MemberService memberService; + + @Mock + ItemRepository itemRepository; + @Mock ItemSearchRepository itemSearchRepository; + @Mock + InventoryRepository inventoryRepository; + @Mock InventorySearchRepository inventorySearchRepository; + @Mock + BugHistoryRepository bugHistoryRepository; + @DisplayName("아이템 목록을 조회한다.") @Test void get_products_success() { @@ -61,6 +82,68 @@ void get_products_success() { 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.getById(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(memberService).getById(memberId); + verify(itemRepository).findById(itemId); + verify(inventorySearchRepository).findOne(memberId, itemId); + verify(inventoryRepository).save(any(Inventory.class)); + verify(bugHistoryRepository).save(any(BugHistory.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 { @@ -71,8 +154,8 @@ void success() { // given Long memberId = 1L; Long itemId = 1L; - Inventory inventory = InventoryFixture.inventory(memberId, nightMageSkin()); - Inventory defaultInventory = InventoryFixture.inventory(memberId, nightMageSkin()); + Inventory inventory = inventory(memberId, nightMageSkin()); + Inventory defaultInventory = inventory(memberId, nightMageSkin()); ItemType itemType = inventory.getItemType(); given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.of(inventory)); given(inventorySearchRepository.findDefault(memberId, itemType)).willReturn(Optional.of(defaultInventory)); diff --git a/src/test/java/com/moabam/api/domain/entity/BugTest.java b/src/test/java/com/moabam/api/domain/entity/BugTest.java index c356fd58..03b906dd 100644 --- a/src/test/java/com/moabam/api/domain/entity/BugTest.java +++ b/src/test/java/com/moabam/api/domain/entity/BugTest.java @@ -1,11 +1,16 @@ package com.moabam.api.domain.entity; +import static com.moabam.support.fixture.BugFixture.*; 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.entity.enums.BugType; import com.moabam.global.error.exception.BadRequestException; class BugTest { @@ -27,4 +32,48 @@ void validate_bug_count_exception(int morningBug, int nightBug, int goldenBug) { .isInstanceOf(BadRequestException.class) .hasMessage("벌레 개수는 0 이상이어야 합니다."); } + + @DisplayName("벌레를 사용한다.") + @Nested + class Use { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Bug bug = bug(); + + // when, then + assertDoesNotThrow(() -> bug.use(BugType.MORNING, 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.increaseBug(BugType.MORNING, 5); + bug.increaseBug(BugType.NIGHT, 5); + bug.increaseBug(BugType.GOLDEN, 5); + + // then + assertThat(bug.getMorningBug()).isEqualTo(15); + assertThat(bug.getNightBug()).isEqualTo(25); + assertThat(bug.getGoldenBug()).isEqualTo(35); + } } diff --git a/src/test/java/com/moabam/api/domain/entity/ItemTest.java b/src/test/java/com/moabam/api/domain/entity/ItemTest.java new file mode 100644 index 00000000..1441732e --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/ItemTest.java @@ -0,0 +1,117 @@ +package com.moabam.api.domain.entity; + +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.entity.enums.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/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 383a8e5b..46e25c6d 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -22,9 +22,11 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.moabam.api.application.ItemService; import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.entity.enums.BugType; import com.moabam.api.domain.entity.enums.ItemType; import com.moabam.api.dto.ItemMapper; import com.moabam.api.dto.ItemsResponse; +import com.moabam.api.dto.PurchaseItemRequest; @WebMvcTest(ItemController.class) class ItemControllerTest { @@ -62,6 +64,23 @@ void get_items_success() throws Exception { assertThat(actual).isEqualTo(expected); } + @DisplayName("아이템을 구매한다.") + @Test + void purchase_item_success() throws Exception { + // given + Long memberId = 1L; + Long itemId = 1L; + PurchaseItemRequest request = new PurchaseItemRequest(BugType.MORNING); + + // when, then + mockMvc.perform(post("/items/{itemId}/purchase", itemId) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andExpect(status().isOk()); + verify(itemService).purchaseItem(memberId, itemId, request); + } + @DisplayName("아이템을 적용한다.") @Test void select_item_success() throws Exception { From e52adc592195d90ae2b0ae9c38ca76f8a37fbe50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Thu, 9 Nov 2023 19:14:55 +0900 Subject: [PATCH 028/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=ED=96=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#57)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 쿠폰 엔티티 설계 * test: Coupon Entity 테스트 * refactor: 초기값 0에서 1로 지정 * feat: 쿠폰 종류에 대한 조회 처리 구현 및 테스트 * refactor: 쿠폰 컬럼으로 관리자 아이디 추가 * feat: 관리자의 쿠폰 생성 기능 구현 * test: 쿠폰 발행 기능 테스트 * test: 쿠폰 엔티티 테스트 추가 * style: test 메서드 변경 * fix: CheckStyle 수정 --- src/docs/asciidoc/coupon.adoc | 35 ++ .../moabam/api/application/CouponService.java | 45 ++ .../com/moabam/api/domain/entity/Coupon.java | 93 +++ .../api/domain/entity/enums/CouponType.java | 38 ++ .../domain/repository/CouponRepository.java | 10 + .../java/com/moabam/api/dto/CouponMapper.java | 24 + .../moabam/api/dto/CreateCouponRequest.java | 27 + .../api/presentation/CouponController.java | 27 + .../global/error/model/ErrorMessage.java | 8 +- src/main/resources/static/docs/coupon.html | 540 ++++++++++++++++++ .../api/application/CouponServiceTest.java | 89 +++ .../moabam/api/domain/entity/CouponTest.java | 63 ++ .../domain/entity/enums/CouponTypeTest.java | 31 + .../api/dto/CreateCouponRequestTest.java | 31 + .../presentation/CouponControllerTest.java | 80 +++ .../moabam/support/fixture/CouponFixture.java | 34 ++ .../support/fixture/CouponSnippetFixture.java | 19 + 17 files changed, 1193 insertions(+), 1 deletion(-) create mode 100644 src/docs/asciidoc/coupon.adoc create mode 100644 src/main/java/com/moabam/api/application/CouponService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/Coupon.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/CouponType.java create mode 100644 src/main/java/com/moabam/api/domain/repository/CouponRepository.java create mode 100644 src/main/java/com/moabam/api/dto/CouponMapper.java create mode 100644 src/main/java/com/moabam/api/dto/CreateCouponRequest.java create mode 100644 src/main/java/com/moabam/api/presentation/CouponController.java create mode 100644 src/main/resources/static/docs/coupon.html create mode 100644 src/test/java/com/moabam/api/application/CouponServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/CouponTest.java create mode 100644 src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java create mode 100644 src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java create mode 100644 src/test/java/com/moabam/api/presentation/CouponControllerTest.java create mode 100644 src/test/java/com/moabam/support/fixture/CouponFixture.java create mode 100644 src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc new file mode 100644 index 00000000..f06a91ef --- /dev/null +++ b/src/docs/asciidoc/coupon.adoc @@ -0,0 +1,35 @@ +== 쿠폰(Coupon) + + 쿠폰에 대해 생성/삭제/조회/발급/사용 기능을 제공합니다. + +=== 쿠폰 생성 + + 관리자가 쿠폰을 생성합니다. + +[discrete] +==== 요청 + +include::{snippets}/coupons/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/coupons/http-response.adoc[] + +=== 쿠폰 삭제 (진행 중) + + 관리자가 쿠폰을 삭제합니다. + +=== 쿠폰 조회 (진행 중) + + 관리자 혹은 사용자가 쿠폰들을 조회합니다. + + 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. + +=== 쿠폰 발급 (진행 중) + + 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다. + +=== 쿠폰 사용 (진행 중) + + 사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다. diff --git a/src/main/java/com/moabam/api/application/CouponService.java b/src/main/java/com/moabam/api/application/CouponService.java new file mode 100644 index 00000000..7f8609bd --- /dev/null +++ b/src/main/java/com/moabam/api/application/CouponService.java @@ -0,0 +1,45 @@ +package com.moabam.api.application; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.domain.repository.CouponRepository; +import com.moabam.api.dto.CouponMapper; +import com.moabam.api.dto.CreateCouponRequest; +import com.moabam.global.error.exception.BadRequestException; +import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.error.model.ErrorMessage; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CouponService { + + private final CouponRepository couponRepository; + + @Transactional + public void createCoupon(Long adminId, CreateCouponRequest request) { + validateConflictCouponName(request.name()); + validateCouponPeriod(request.startAt(), request.endAt()); + + Coupon coupon = CouponMapper.toEntity(adminId, request); + couponRepository.save(coupon); + } + + private void validateConflictCouponName(String name) { + if (couponRepository.existsByName(name)) { + throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); + } + } + + private void validateCouponPeriod(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt)) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); + } + } +} diff --git a/src/main/java/com/moabam/api/domain/entity/Coupon.java b/src/main/java/com/moabam/api/domain/entity/Coupon.java new file mode 100644 index 00000000..102da46f --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/Coupon.java @@ -0,0 +1,93 @@ +package com.moabam.api.domain.entity; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.util.Objects.*; + +import java.time.LocalDateTime; + +import org.hibernate.annotations.ColumnDefault; + +import com.moabam.api.domain.entity.enums.CouponType; +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; + + @Column(name = "description", length = 50) + private String description; + + @Enumerated(value = EnumType.STRING) + @Column(name = "type", nullable = false) + private CouponType type; + + @ColumnDefault("1") + @Column(name = "stock", nullable = false) + private int stock; + + @Column(name = "start_at", nullable = false) + private LocalDateTime startAt; + + @Column(name = "end_at", nullable = false) + private LocalDateTime endAt; + + @Column(name = "admin_id", updatable = false, nullable = false) + private Long adminId; + + @Builder + private Coupon(String name, int point, String description, CouponType type, int stock, LocalDateTime startAt, + LocalDateTime endAt, Long adminId) { + this.name = requireNonNull(name); + this.point = validatePoint(point); + this.description = description; + this.type = requireNonNull(type); + this.stock = validateStock(stock); + this.startAt = requireNonNull(startAt); + this.endAt = requireNonNull(endAt); + this.adminId = requireNonNull(adminId); + } + + 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/entity/enums/CouponType.java b/src/main/java/com/moabam/api/domain/entity/enums/CouponType.java new file mode 100644 index 00000000..9cdb78de --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/CouponType.java @@ -0,0 +1,38 @@ +package com.moabam.api.domain.entity.enums; + +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.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_COUPON("아침"), + NIGHT_COUPON("저녁"), + GOLDEN_COUPON("황금"), + DISCOUNT_COUPON("할인"); + + private final String typeName; + private static final Map COUPON_TYPE_MAP; + + static { + COUPON_TYPE_MAP = Collections.unmodifiableMap(Arrays.stream(values()) + .collect(Collectors.toMap(CouponType::getTypeName, Function.identity()))); + } + + public static CouponType from(String typeName) { + return Optional.ofNullable(COUPON_TYPE_MAP.get(typeName)) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_TYPE)); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/CouponRepository.java b/src/main/java/com/moabam/api/domain/repository/CouponRepository.java new file mode 100644 index 00000000..1a262050 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/CouponRepository.java @@ -0,0 +1,10 @@ +package com.moabam.api.domain.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.entity.Coupon; + +public interface CouponRepository extends JpaRepository { + + boolean existsByName(String name); +} diff --git a/src/main/java/com/moabam/api/dto/CouponMapper.java b/src/main/java/com/moabam/api/dto/CouponMapper.java new file mode 100644 index 00000000..400f232d --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CouponMapper.java @@ -0,0 +1,24 @@ +package com.moabam.api.dto; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.domain.entity.enums.CouponType; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class CouponMapper { + + public static Coupon toEntity(Long adminId, CreateCouponRequest request) { + return Coupon.builder() + .name(request.name()) + .description(request.description()) + .type(CouponType.from(request.type())) + .point(request.point()) + .stock(request.stock()) + .startAt(request.startAt()) + .endAt(request.endAt()) + .adminId(adminId) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/dto/CreateCouponRequest.java b/src/main/java/com/moabam/api/dto/CreateCouponRequest.java new file mode 100644 index 00000000..710fffc3 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CreateCouponRequest.java @@ -0,0 +1,27 @@ +package com.moabam.api.dto; + +import java.time.LocalDateTime; + +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 stock, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") + @NotNull(message = "쿠폰 발급 시작 시각을 입력해주세요.") LocalDateTime startAt, + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") + @NotNull(message = "쿠폰 발급 종료 시각을 입력해주세요.") LocalDateTime endAt +) { + +} 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..acac1ac8 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -0,0 +1,27 @@ +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.CouponService; +import com.moabam.api.dto.CreateCouponRequest; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/admins/coupons") +public class CouponController { + + private final CouponService couponService; + + @PostMapping + @ResponseStatus(HttpStatus.OK) + public void createCoupon(@RequestBody CreateCouponRequest request) { + couponService.createCoupon(1L, request); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index b1f90451..a1e5e5f4 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -45,7 +45,13 @@ public enum ErrorMessage { FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), - CONFLICT_KNOCK("이미 콕 알림을 보낸 대상입니다."); + CONFLICT_KNOCK("이미 콕 알림을 보낸 대상입니다."), + + INVALID_COUPON_POINT("쿠폰의 보너스 포인트는 0 이상이어야 합니다."), + INVALID_COUPON_STOCK("쿠폰의 재고는 0 이상이어야 합니다."), + CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), + NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), + INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."); private final String message; } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html new file mode 100644 index 00000000..a0074984 --- /dev/null +++ b/src/main/resources/static/docs/coupon.html @@ -0,0 +1,540 @@ + + + + + + + +쿠폰(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,
+  "stock" : 10,
+  "startAt" : "2000-01-22T10:30",
+  "endAt" : "2000-02-22T11:00"
+}
+
+
+

응답

+
+
+
HTTP/1.1 409 Conflict
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 62
+
+{
+  "message" : "쿠폰의 이름이 중복되었습니다."
+}
+
+
+
+
+

쿠폰 삭제 (진행 중)

+
+
+
관리자가 쿠폰을 삭제합니다.
+
+
+
+
+

쿠폰 조회 (진행 중)

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

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/CouponServiceTest.java new file mode 100644 index 00000000..2759bec5 --- /dev/null +++ b/src/test/java/com/moabam/api/application/CouponServiceTest.java @@ -0,0 +1,89 @@ +package com.moabam.api.application; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.domain.entity.enums.CouponType; +import com.moabam.api.domain.repository.CouponRepository; +import com.moabam.api.dto.CreateCouponRequest; +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.fixture.CouponFixture; + +@ExtendWith(MockitoExtension.class) +class CouponServiceTest { + + @InjectMocks + private CouponService couponService; + + @Mock + private CouponRepository couponRepository; + + @DisplayName("쿠폰을 성공적으로 발행한다. - Void") + @Test + void couponService_createCoupon() { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + + // When + couponService.createCoupon(1L, request); + + // Then + verify(couponRepository).save(any(Coupon.class)); + } + + @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") + @Test + void couponService_createCoupon_ConflictException() { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + + given(couponRepository.existsByName(any(String.class))).willReturn(true); + + // When & Then + assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.CONFLICT_COUPON_NAME.getMessage()); + } + + @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") + @Test + void couponService_createCoupon_NotFoundException() { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 1, 2); + given(couponRepository.existsByName(any(String.class))).willReturn(false); + + // When & Then + assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + } + + @DisplayName("쿠폰 발급 종료 기간이 시작 기간보다 더 이전인 쿠폰을 발행한다. - BadRequestException") + @Test + void couponService_createCoupon_BadRequestException() { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 1); + given(couponRepository.existsByName(any(String.class))).willReturn(false); + + // When & Then + assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/domain/entity/CouponTest.java b/src/test/java/com/moabam/api/domain/entity/CouponTest.java new file mode 100644 index 00000000..1f68c7de --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/CouponTest.java @@ -0,0 +1,63 @@ +package com.moabam.api.domain.entity; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.entity.enums.CouponType; +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() { + // Given + LocalDateTime startAt = LocalDateTime.of(2000, 1, 22, 10, 30, 0); + LocalDateTime endAt = LocalDateTime.of(2000, 1, 22, 11, 0, 0); + + // When + Coupon actual = Coupon.builder() + .name("couponName") + .point(10) + .type(CouponType.MORNING_COUPON) + .stock(100) + .startAt(startAt) + .endAt(endAt) + .adminId(1L) + .build(); + + // Then + assertThat(actual.getName()).isEqualTo("couponName"); + assertThat(actual.getDescription()).isNull(); + assertThat(actual.getPoint()).isEqualTo(10); + assertThat(actual.getStock()).isEqualTo(100); + assertThat(actual.getType()).isEqualTo(CouponType.MORNING_COUPON); + assertThat(actual.getStartAt()).isEqualTo(startAt); + assertThat(actual.getEndAt()).isEqualTo(endAt); + assertThat(actual.getAdminId()).isEqualTo(1L); + } + + @DisplayName("쿠폰 보너스 포인트가 1보다 작다. - BadRequestException") + @Test + void coupon_validatePoint_Point_BadRequestException() { + // When& Then + assertThatThrownBy(() -> CouponFixture.coupon(0, 1)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_POINT.getMessage()); + } + + @DisplayName("쿠폰 재고가 1보다 작다. - BadRequestException") + @Test + void coupon_validatePoint_Stock_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/entity/enums/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java new file mode 100644 index 00000000..4ceed56f --- /dev/null +++ b/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java @@ -0,0 +1,31 @@ +package com.moabam.api.domain.entity.enums; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; + +class CouponTypeTest { + + @DisplayName("존재하는 쿠폰을 가져온다. - CouponType") + @Test + void couponType_from() { + // When + CouponType actual = CouponType.from("황금"); + + // Then + assertThat(actual).isEqualTo(CouponType.GOLDEN_COUPON); + } + + @DisplayName("존재하지 않는 쿠폰을 가져온다. - NotFoundException") + @Test + void couponType_from_NotFoundException() { + // When & Then + assertThatThrownBy(() -> CouponType.from("Not-Coupon")) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + } +} diff --git a/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java new file mode 100644 index 00000000..18d2c481 --- /dev/null +++ b/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java @@ -0,0 +1,31 @@ +package com.moabam.api.dto; + +import static org.assertj.core.api.Assertions.*; + +import java.time.LocalDateTime; + +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'T'HH:mm") + @Test + void createCouponRequest_StartAt() throws JsonProcessingException { + // Given + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + + String json = "{\"startAt\":\"2023-11-09T10:10\"}"; + + // When + CreateCouponRequest actual = objectMapper.readValue(json, CreateCouponRequest.class); + + // Then + assertThat(actual.startAt()).isEqualTo(LocalDateTime.of(2023, 11, 9, 10, 10)); + } +} 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..215e0b85 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -0,0 +1,80 @@ +package com.moabam.api.presentation; + +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 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.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.entity.enums.CouponType; +import com.moabam.api.domain.repository.CouponRepository; +import com.moabam.api.dto.CouponMapper; +import com.moabam.api.dto.CreateCouponRequest; +import com.moabam.support.fixture.CouponFixture; +import com.moabam.support.fixture.CouponSnippetFixture; + +@Transactional +@SpringBootTest +@AutoConfigureMockMvc +@AutoConfigureRestDocs +class CouponControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private CouponRepository couponRepository; + + @DisplayName("쿠폰을 성공적으로 발행한다. - Void") + @Test + void couponController_createCoupon() throws Exception { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + + // When & Then + mockMvc.perform(post("/admins/coupons") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippetFixture.CREATE_COUPON_REQUEST)) + .andExpect(status().isOk()); + } + + @DisplayName("쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") + @Test + void couponController_createCoupon_ConflictException() throws Exception { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + 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("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + CouponSnippetFixture.CREATE_COUPON_REQUEST)) + .andExpect(status().isConflict()); + } +} 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..6dbc68b8 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -0,0 +1,34 @@ +package com.moabam.support.fixture; + +import java.time.LocalDateTime; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.domain.entity.enums.CouponType; +import com.moabam.api.dto.CreateCouponRequest; + +public final class CouponFixture { + + public static Coupon coupon(int point, int stock) { + return Coupon.builder() + .name("couponName") + .point(point) + .type(CouponType.MORNING_COUPON) + .stock(stock) + .startAt(LocalDateTime.of(2000, 1, 22, 10, 30, 0)) + .endAt(LocalDateTime.of(2000, 1, 22, 11, 0, 0)) + .adminId(1L) + .build(); + } + + public static CreateCouponRequest createCouponRequest(String couponType, int startMonth, int endMonth) { + return CreateCouponRequest.builder() + .name("couponName") + .description("coupon description") + .point(10) + .type(couponType) + .stock(10) + .startAt(LocalDateTime.of(2000, startMonth, 22, 10, 30, 0)) + .endAt(LocalDateTime.of(2000, endMonth, 22, 11, 0, 0)) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java new file mode 100644 index 00000000..92345c0e --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java @@ -0,0 +1,19 @@ +package com.moabam.support.fixture; + +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; + +import org.springframework.restdocs.payload.RequestFieldsSnippet; + +public final class CouponSnippetFixture { + + 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("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), + fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), + fieldWithPath("endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + ); +} From 4aeb539b2d4cdea4942fb53bda6a085e67e54135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Fri, 10 Nov 2023 21:11:20 +0900 Subject: [PATCH 029/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 쿠폰 삭제 기능 구현 * test: 쿠폰 삭제 기능 테스트 * test: 테스트 Display 및 Adoc 수정 * test: RestDoc 문서 결과 --- src/docs/asciidoc/coupon.adoc | 16 +++++++-- src/docs/asciidoc/notification.adoc | 4 +-- .../moabam/api/application/CouponService.java | 8 +++++ .../api/presentation/CouponController.java | 14 ++++++-- .../global/error/model/ErrorMessage.java | 3 +- src/main/resources/static/docs/coupon.html | 26 ++++++++++++-- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../api/application/CouponServiceTest.java | 28 +++++++++++++++ .../application/NotificationServiceTest.java | 16 ++++----- .../NotificationRepositoryTest.java | 24 ++++++------- .../presentation/CouponControllerTest.java | 34 +++++++++++++++++-- .../NotificationControllerTest.java | 12 +++---- .../repository/StringRedisRepositoryTest.java | 8 ++--- 14 files changed, 151 insertions(+), 46 deletions(-) diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc index f06a91ef..cbbd5286 100644 --- a/src/docs/asciidoc/coupon.adoc +++ b/src/docs/asciidoc/coupon.adoc @@ -9,16 +9,26 @@ [discrete] ==== 요청 -include::{snippets}/coupons/http-request.adoc[] +include::{snippets}/admins/coupons/http-request.adoc[] [discrete] ==== 응답 -include::{snippets}/coupons/http-response.adoc[] +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[] === 쿠폰 조회 (진행 중) diff --git a/src/docs/asciidoc/notification.adoc b/src/docs/asciidoc/notification.adoc index e7004b80..b0e3bb1c 100644 --- a/src/docs/asciidoc/notification.adoc +++ b/src/docs/asciidoc/notification.adoc @@ -12,9 +12,9 @@ [discrete] ==== 요청 -include::{snippets}/notifications/http-request.adoc[] +include::{snippets}/notifications/rooms/roomId/members/memberId/http-request.adoc[] [discrete] ==== 응답 -include::{snippets}/notifications/http-response.adoc[] +include::{snippets}/notifications/rooms/roomId/members/memberId/http-response.adoc[] diff --git a/src/main/java/com/moabam/api/application/CouponService.java b/src/main/java/com/moabam/api/application/CouponService.java index 7f8609bd..07690cfb 100644 --- a/src/main/java/com/moabam/api/application/CouponService.java +++ b/src/main/java/com/moabam/api/application/CouponService.java @@ -11,6 +11,7 @@ import com.moabam.api.dto.CreateCouponRequest; 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; @@ -31,6 +32,13 @@ public void createCoupon(Long adminId, CreateCouponRequest request) { couponRepository.save(coupon); } + @Transactional + public void deleteCoupon(Long adminId, Long couponId) { + Coupon coupon = couponRepository.findById(couponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + couponRepository.delete(coupon); + } + private void validateConflictCouponName(String name) { if (couponRepository.existsByName(name)) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index acac1ac8..06ca6cc5 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -1,6 +1,8 @@ package com.moabam.api.presentation; import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.DeleteMapping; +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; @@ -14,14 +16,20 @@ @RestController @RequiredArgsConstructor -@RequestMapping("/admins/coupons") +@RequestMapping("/admins") public class CouponController { private final CouponService couponService; - @PostMapping - @ResponseStatus(HttpStatus.OK) + @PostMapping("/coupons") + @ResponseStatus(HttpStatus.CREATED) public void createCoupon(@RequestBody CreateCouponRequest request) { couponService.createCoupon(1L, request); } + + @DeleteMapping("/coupons/{couponId}") + @ResponseStatus(HttpStatus.OK) + public void deleteCoupon(@PathVariable Long couponId) { + couponService.deleteCoupon(1L, couponId); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index a1e5e5f4..8efa2059 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -49,9 +49,10 @@ public enum ErrorMessage { INVALID_COUPON_POINT("쿠폰의 보너스 포인트는 0 이상이어야 합니다."), INVALID_COUPON_STOCK("쿠폰의 재고는 0 이상이어야 합니다."), + INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), - INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."); + NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."); private final String message; } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index a0074984..9d1b2145 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -494,7 +494,29 @@

응답

쿠폰 삭제 (진행 중)

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

요청

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

응답

+
+
+
HTTP/1.1 404 Not Found
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 56
+
+{
+  "message" : "존재하지 않는 쿠폰입니다."
+}
@@ -533,7 +555,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 2eb579ec..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 2dff8a58..31c8fe45 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/CouponServiceTest.java index 2759bec5..5cf985c5 100644 --- a/src/test/java/com/moabam/api/application/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/CouponServiceTest.java @@ -3,6 +3,8 @@ 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.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -86,4 +88,30 @@ void couponService_createCoupon_BadRequestException() { .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } + + @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 삭제한다. - Void") + @Test + void couponService_deleteCoupon() { + // Given + Coupon coupon = CouponFixture.coupon(10, 100); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); + + // When + couponService.deleteCoupon(1L, 1L); + + // Then + verify(couponRepository).delete(coupon); + } + + @DisplayName("존재하지 않는 쿠폰 아이디를 삭제하려고 시도한다. - NotFoundException") + @Test + void couponService_deleteCoupon_NotFoundException() { + // Given + given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponService.deleteCoupon(1L, 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); + } } diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index f06482a4..2e8d99d1 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -51,7 +51,7 @@ void setUp() { memberTest = new MemberTest(2L, "nickname"); } - @DisplayName("성공적으로 상대를 콕 찔렀을 때, - Void") + @DisplayName("성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test void notificationService_sendKnockNotification() { // Given @@ -68,7 +68,7 @@ void notificationService_sendKnockNotification() { verify(notificationRepository).saveKnockNotification(any(String.class)); } - @DisplayName("콕 찌를 상대의 방이 존재하지 않을 때, - NotFoundException") + @DisplayName("콕 찌를 상대의 방이 존재하지 않는다. - NotFoundException") @Test void notificationService_sendKnockNotification_Room_NotFoundException() { // Given @@ -79,7 +79,7 @@ void notificationService_sendKnockNotification_Room_NotFoundException() { .isInstanceOf(NotFoundException.class); } - @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않을 때, - NotFoundException") + @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") @Test void notificationService_sendKnockNotification_FcmToken_NotFoundException() { // Given @@ -93,7 +93,7 @@ void notificationService_sendKnockNotification_FcmToken_NotFoundException() { .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); } - @DisplayName("콕 찌를 상대가 이미 찌른 상대일 때, - ConflictException") + @DisplayName("콕 찌를 상대가 이미 찌른 상대이다. - ConflictException") @Test void notificationService_sendKnockNotification_ConflictException() { // Given @@ -106,7 +106,7 @@ void notificationService_sendKnockNotification_ConflictException() { .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } - @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낼 때, - Void") + @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낸다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest void notificationService_sendCertificationTimeNotification(List participants) { @@ -121,7 +121,7 @@ void notificationService_sendCertificationTimeNotification(List par verify(firebaseMessaging, times(3)).sendAsync(any(Message.class)); } - @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없을 때, - Void") + @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(List participants) { @@ -136,7 +136,7 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); } - @DisplayName("특정 방에서 나 이외의 모든 사용자를 콕 찔렀을 때, - KnockNotificationStatusResponse") + @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸다. - KnockNotificationStatusResponse") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { @@ -154,7 +154,7 @@ void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { diff --git a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java index 6198be33..7f4782ff 100644 --- a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java @@ -23,7 +23,7 @@ class NotificationRepositoryTest { @Mock private StringRedisRepository stringRedisRepository; - @DisplayName("FCM 토큰이 성공적으로 저장 될 때, - Void") + @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") @Test void notificationRepository_saveFcmToken() { // When @@ -33,7 +33,7 @@ void notificationRepository_saveFcmToken() { verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); } - @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_save_NullPointerException() { // When & Then @@ -41,7 +41,7 @@ void notificationRepository_save_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("콕 알림이 성공적으로 저장 될 때, - Void") + @DisplayName("콕 알림이 성공적으로 저장된다. - Void") @Test void notificationRepository_saveKnockNotification() { // When @@ -51,7 +51,7 @@ void notificationRepository_saveKnockNotification() { verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); } - @DisplayName("콕 알림 저장 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("콕 알림 저장 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_saveKnockNotification_NullPointerException() { // When & Then @@ -59,7 +59,7 @@ void notificationRepository_saveKnockNotification_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("FCM 토큰이 성공적으로 삭제 될 때, - Void") + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") @Test void notificationRepository_deleteFcmTokenByMemberId() { // When @@ -69,7 +69,7 @@ void notificationRepository_deleteFcmTokenByMemberId() { verify(stringRedisRepository).delete(any(String.class)); } - @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_deleteFcmTokenByMemberId_NullPointerException() { // When & Then @@ -77,7 +77,7 @@ void notificationRepository_deleteFcmTokenByMemberId_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("FCM 토큰을 성공적으로 조회할 때, - (String) FCM TOKEN") + @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") @Test void notificationRepository_findFcmTokenByMemberId() { // When @@ -87,7 +87,7 @@ void notificationRepository_findFcmTokenByMemberId() { verify(stringRedisRepository).get(any(String.class)); } - @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_findFcmTokenByMemberId_NullPointerException() { // When & Then @@ -95,7 +95,7 @@ void notificationRepository_findFcmTokenByMemberId_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인 할 때, - Boolean") + @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인한다. - Boolean") @Test void notificationRepository_existsFcmTokenByMemberId() { // When @@ -105,7 +105,7 @@ void notificationRepository_existsFcmTokenByMemberId() { verify(stringRedisRepository).hasKey(any(String.class)); } - @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_existsFcmTokenByMemberId_NullPointerException() { // When & Then @@ -113,7 +113,7 @@ void notificationRepository_existsFcmTokenByMemberId_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("콕 알림 여부 체크를 정상적으로 확인할 때, - Boolean") + @DisplayName("콕 알림 여부 체크를 정상적으로 확인한다. - Boolean") @Test void notificationRepository_existsKnockByMemberId() { // When @@ -123,7 +123,7 @@ void notificationRepository_existsKnockByMemberId() { verify(stringRedisRepository).hasKey(any(String.class)); } - @DisplayName("콕 알림 여부 체크 시, 필요한 값이 NULL 일 때, - NullPointerException") + @DisplayName("콕 알림 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") @Test void notificationRepository_existsKnockByMemberId_NullPointerException() { // When & Then diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 215e0b85..5d1cdeae 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -17,6 +17,7 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; +import com.moabam.api.domain.entity.Coupon; import com.moabam.api.domain.entity.enums.CouponType; import com.moabam.api.domain.repository.CouponRepository; import com.moabam.api.dto.CouponMapper; @@ -51,11 +52,11 @@ void couponController_createCoupon() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) - .andDo(document("coupons", + .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), CouponSnippetFixture.CREATE_COUPON_REQUEST)) - .andExpect(status().isOk()); + .andExpect(status().isCreated()); } @DisplayName("쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") @@ -71,10 +72,37 @@ void couponController_createCoupon_ConflictException() throws Exception { .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) .andDo(print()) - .andDo(document("coupons", + .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), CouponSnippetFixture.CREATE_COUPON_REQUEST)) .andExpect(status().isConflict()); } + + @DisplayName("쿠폰을 성공적으로 삭제한다. - Void") + @Test + void couponController_deleteCoupon() 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()); + } + + @DisplayName("존재하지 않는 쿠폰을 삭제한다. - NotFoundException") + @Test + void couponController_deleteCoupon_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(delete("/admins/coupons/77777777777")) + .andDo(print()) + .andDo(document("admins/coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isNotFound()); + } } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 3edbab12..7d21071c 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -76,7 +76,7 @@ void setDown() { stringRedisRepository.delete(knockKey); } - @DisplayName("GET - 성공적으로 상대를 찔렀을 때, - Void") + @DisplayName("GET - 성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test void notificationController_sendKnockNotification() throws Exception { // Given @@ -85,25 +85,25 @@ void notificationController_sendKnockNotification() throws Exception { // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) .andDo(print()) - .andDo(document("notifications", + .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()))) .andExpect(status().isOk()); } - @DisplayName("GET - 찌른 상대가 접속 중이 아닐 때, - NotFoundException") + @DisplayName("GET - 콕 알림을 보낸 상대가 접속 중이 아니다. - NotFoundException") @Test void notificationController_sendKnockNotification_NotFoundException() throws Exception { // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) .andDo(print()) - .andDo(document("notifications", + .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()))) .andExpect(status().isNotFound()); } - @DisplayName("GET - 이미 찌른 대상일 때, - ConflictException") + @DisplayName("GET - 이미 콕 알림을 보낸 대상이다. - ConflictException") @Test void notificationController_sendKnockNotification_ConflictException() throws Exception { // Given @@ -113,7 +113,7 @@ void notificationController_sendKnockNotification_ConflictException() throws Exc // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) .andDo(print()) - .andDo(document("notifications", + .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()))) .andExpect(status().isConflict()); diff --git a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java index 0deb6cea..40af28e0 100644 --- a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java @@ -34,14 +34,14 @@ void setDown() { stringRedisRepository.delete(key); } - @DisplayName("레디스에 문자열 데이터가 성공적으로 저장될 때, - Void") + @DisplayName("레디스에 문자열 데이터가 성공적으로 저장된다. - Void") @Test void string_redis_repository_save() { // Then assertThat(stringRedisRepository.get(key)).isEqualTo(value); } - @DisplayName("레디스의 특정 데이터가 성공적으로 삭제될 때, - Void") + @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") @Test void string_redis_repository_delete() { // When @@ -51,7 +51,7 @@ void string_redis_repository_delete() { assertThat(stringRedisRepository.hasKey(key)).isFalse(); } - @DisplayName("레디스의 특정 데이터가 성공적으로 조회될 때, - String(Value)") + @DisplayName("레디스의 특정 데이터가 성공적으로 조회된다. - String(Value)") @Test void string_redis_repository_get() { // When @@ -61,7 +61,7 @@ void string_redis_repository_get() { assertThat(actual).isEqualTo(stringRedisRepository.get(key)); } - @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크할 때, - Boolean") + @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") @Test void string_redis_repository_hasKey() { // When & Then From b5f9a450e8eb3b4f4527d412d519e6afc9c16365 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Sun, 12 Nov 2023 15:42:40 +0900 Subject: [PATCH 030/185] =?UTF-8?q?feat:=20=ED=8A=B9=EC=A0=95=20=EC=BF=A0?= =?UTF-8?q?=ED=8F=B0=20=EB=B0=8F=20=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EC=BF=A0=ED=8F=B0=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20(#60)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 쿠폰 삭제 기능 구현 * test: 쿠폰 삭제 기능 테스트 * test: 테스트 Display 및 Adoc 수정 * test: RestDoc 문서 결과 * refactor: type -> couponType으로 변경 * feat: 쿠폰 상태에 따른 조회 및 특정 쿠폰 조회 기능 구현 * fix: 쿼리 에러 해결 및 CouponResponse 위치 변경 * fix: 상태에 따른 잘못된 쿼리 수정 * test: 특정 쿠폰 및 상태에 따른 쿠폰 조회 기능 테스트 * test: 리뷰 반영 --- src/docs/asciidoc/coupon.adoc | 44 +++- .../moabam/api/application/CouponService.java | 21 ++ .../com/moabam/api/domain/entity/Coupon.java | 19 +- .../repository/CouponSearchRepository.java | 80 ++++++++ .../java/com/moabam/api/dto/CouponMapper.java | 17 +- .../com/moabam/api/dto/CouponResponse.java | 25 +++ .../moabam/api/dto/CouponSearchRequest.java | 12 ++ .../moabam/api/dto/CreateCouponRequest.java | 2 +- .../api/presentation/CouponController.java | 26 ++- src/main/resources/static/docs/coupon.html | 88 +++++++- .../resources/static/docs/notification.html | 2 +- .../api/application/CouponServiceTest.java | 53 +++++ .../moabam/api/domain/entity/CouponTest.java | 6 +- .../CouponSearchRepositoryTest.java | 194 ++++++++++++++++++ .../presentation/CouponControllerTest.java | 122 ++++++++++- .../NotificationControllerTest.java | 17 +- .../moabam/support/fixture/CouponFixture.java | 54 ++++- .../support/fixture/CouponSnippetFixture.java | 36 +++- .../support/fixture/ErrorSnippetFixture.java | 13 ++ 19 files changed, 789 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/CouponResponse.java create mode 100644 src/main/java/com/moabam/api/dto/CouponSearchRequest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc index cbbd5286..9a939706 100644 --- a/src/docs/asciidoc/coupon.adoc +++ b/src/docs/asciidoc/coupon.adoc @@ -2,6 +2,8 @@ 쿠폰에 대해 생성/삭제/조회/발급/사용 기능을 제공합니다. +--- + === 쿠폰 생성 관리자가 쿠폰을 생성합니다. @@ -16,7 +18,9 @@ include::{snippets}/admins/coupons/http-request.adoc[] include::{snippets}/admins/coupons/http-response.adoc[] -=== 쿠폰 삭제 (진행 중) +--- + +=== 쿠폰 삭제 관리자가 쿠폰 ID와 일치하는 쿠폰을 삭제합니다. @@ -30,16 +34,50 @@ include::{snippets}/admins/coupons/couponId/http-request.adoc[] 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[] + +--- + +=== 특정 사용자의 쿠폰 보관함을 조회 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. +--- + === 쿠폰 발급 (진행 중) 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다. +--- + === 쿠폰 사용 (진행 중) 사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다. diff --git a/src/main/java/com/moabam/api/application/CouponService.java b/src/main/java/com/moabam/api/application/CouponService.java index 07690cfb..db43a892 100644 --- a/src/main/java/com/moabam/api/application/CouponService.java +++ b/src/main/java/com/moabam/api/application/CouponService.java @@ -1,13 +1,17 @@ package com.moabam.api.application; import java.time.LocalDateTime; +import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.moabam.api.domain.entity.Coupon; import com.moabam.api.domain.repository.CouponRepository; +import com.moabam.api.domain.repository.CouponSearchRepository; import com.moabam.api.dto.CouponMapper; +import com.moabam.api.dto.CouponResponse; +import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; @@ -22,6 +26,7 @@ public class CouponService { private final CouponRepository couponRepository; + private final CouponSearchRepository couponSearchRepository; @Transactional public void createCoupon(Long adminId, CreateCouponRequest request) { @@ -39,6 +44,22 @@ public void deleteCoupon(Long adminId, Long couponId) { couponRepository.delete(coupon); } + public CouponResponse getCouponById(Long couponId) { + Coupon coupon = couponSearchRepository.findById(couponId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + + return CouponMapper.toDto(coupon); + } + + public List getCoupons(CouponSearchRequest request) { + LocalDateTime now = LocalDateTime.now(); + List coupons = couponSearchRepository.findAllByStatus(now, request); + + return coupons.stream() + .map(CouponMapper::toDto) + .toList(); + } + private void validateConflictCouponName(String name) { if (couponRepository.existsByName(name)) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); diff --git a/src/main/java/com/moabam/api/domain/entity/Coupon.java b/src/main/java/com/moabam/api/domain/entity/Coupon.java index 102da46f..0b6c8ae9 100644 --- a/src/main/java/com/moabam/api/domain/entity/Coupon.java +++ b/src/main/java/com/moabam/api/domain/entity/Coupon.java @@ -1,9 +1,11 @@ package com.moabam.api.domain.entity; +import static com.moabam.global.common.util.GlobalConstant.*; import static com.moabam.global.error.model.ErrorMessage.*; import static java.util.Objects.*; import java.time.LocalDateTime; +import java.util.Optional; import org.hibernate.annotations.ColumnDefault; @@ -42,12 +44,13 @@ public class Coupon extends BaseTimeEntity { @Column(name = "point", nullable = false) private int point; + @ColumnDefault("''") @Column(name = "description", length = 50) private String description; @Enumerated(value = EnumType.STRING) - @Column(name = "type", nullable = false) - private CouponType type; + @Column(name = "coupon_type", nullable = false) + private CouponType couponType; @ColumnDefault("1") @Column(name = "stock", nullable = false) @@ -59,16 +62,17 @@ public class Coupon extends BaseTimeEntity { @Column(name = "end_at", nullable = false) private LocalDateTime endAt; + // TODO : 관리자 테이블 생기면 관리자 테이블이랑 다대일 관계 맺을 예정 @Column(name = "admin_id", updatable = false, nullable = false) private Long adminId; @Builder - private Coupon(String name, int point, String description, CouponType type, int stock, LocalDateTime startAt, + private Coupon(String name, int point, String description, CouponType couponType, int stock, LocalDateTime startAt, LocalDateTime endAt, Long adminId) { this.name = requireNonNull(name); this.point = validatePoint(point); - this.description = description; - this.type = requireNonNull(type); + this.description = Optional.ofNullable(description).orElse(BLANK); + this.couponType = requireNonNull(couponType); this.stock = validateStock(stock); this.startAt = requireNonNull(startAt); this.endAt = requireNonNull(endAt); @@ -90,4 +94,9 @@ private int validateStock(int stock) { return stock; } + + @Override + public String toString() { + return "Coupon{startAt=" + startAt + ", endAt=" + endAt + '}'; + } } diff --git a/src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java new file mode 100644 index 00000000..50631751 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java @@ -0,0 +1,80 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.entity.QCoupon.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.dto.CouponSearchRequest; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findById(Long couponId) { + return Optional.ofNullable( + jpaQueryFactory.selectFrom(coupon) + .where(coupon.id.eq(couponId)) + .fetchOne() + ); + } + + public List findAllByStatus(LocalDateTime now, CouponSearchRequest request) { + return jpaQueryFactory.selectFrom(coupon) + .where(filterCouponStatus(now, request)) + .fetch(); + } + + private BooleanExpression filterCouponStatus(LocalDateTime now, CouponSearchRequest request) { + if (request.couponOngoing() && request.couponNotStarted() && request.couponEnded()) { + return null; + } + + // 시작 전이거나 진행 중인 쿠폰들을 조회하고 싶은 경우 + if (request.couponOngoing() && request.couponNotStarted()) { + return (coupon.startAt.gt(now)) + .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); + } + + // 종료 됐거나 진행 중인 쿠폰들을 조회하고 싶은 경우 + if (request.couponOngoing() && request.couponEnded()) { + return (coupon.endAt.lt(now)) + .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); + } + + // 진행 중이 아니고, 시작 전이거나, 종료된 쿠폰들을 조회하고 싶은 경우 + if (request.couponNotStarted() && request.couponEnded()) { + return coupon.startAt.gt(now) + .or(coupon.endAt.lt(now)); + } + + // 진행 중인 쿠폰들을 조회하고 싶은 경우 + if (request.couponOngoing()) { + return coupon.startAt.loe(now) + .and(coupon.endAt.goe(now)); + } + + // 시작 적인 쿠폰들을 조회하고 싶은 경우 + if (request.couponNotStarted()) { + return coupon.startAt.gt(now); + } + + // 종료된 쿠폰들을 조회하고 싶은 경우 + if (request.couponEnded()) { + return coupon.endAt.lt(now); + } + + return Expressions.FALSE; + } +} diff --git a/src/main/java/com/moabam/api/dto/CouponMapper.java b/src/main/java/com/moabam/api/dto/CouponMapper.java index 400f232d..a788d3d5 100644 --- a/src/main/java/com/moabam/api/dto/CouponMapper.java +++ b/src/main/java/com/moabam/api/dto/CouponMapper.java @@ -13,7 +13,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest request) { return Coupon.builder() .name(request.name()) .description(request.description()) - .type(CouponType.from(request.type())) + .couponType(CouponType.from(request.couponType())) .point(request.point()) .stock(request.stock()) .startAt(request.startAt()) @@ -21,4 +21,19 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest request) { .adminId(adminId) .build(); } + + // TODO : Admin Table 생성 시, 관리자 명 추가할 예정 + public static CouponResponse toDto(Coupon coupon) { + return CouponResponse.builder() + .couponId(coupon.getId()) + .couponAdminName(coupon.getAdminId() + "admin") + .name(coupon.getName()) + .description(coupon.getDescription()) + .point(coupon.getPoint()) + .stock(coupon.getStock()) + .couponType(coupon.getCouponType()) + .startAt(coupon.getStartAt()) + .endAt(coupon.getEndAt()) + .build(); + } } diff --git a/src/main/java/com/moabam/api/dto/CouponResponse.java b/src/main/java/com/moabam/api/dto/CouponResponse.java new file mode 100644 index 00000000..bdf24ad6 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CouponResponse.java @@ -0,0 +1,25 @@ +package com.moabam.api.dto; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.moabam.api.domain.entity.enums.CouponType; + +import lombok.Builder; + +@Builder +public record CouponResponse( + Long couponId, + String couponAdminName, + String name, + String description, + int point, + int stock, + CouponType couponType, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime startAt, + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") + LocalDateTime endAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/CouponSearchRequest.java b/src/main/java/com/moabam/api/dto/CouponSearchRequest.java new file mode 100644 index 00000000..2863aef0 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/CouponSearchRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record CouponSearchRequest( + boolean couponOngoing, + boolean couponNotStarted, + boolean couponEnded +) { + +} diff --git a/src/main/java/com/moabam/api/dto/CreateCouponRequest.java b/src/main/java/com/moabam/api/dto/CreateCouponRequest.java index 710fffc3..6b414bcf 100644 --- a/src/main/java/com/moabam/api/dto/CreateCouponRequest.java +++ b/src/main/java/com/moabam/api/dto/CreateCouponRequest.java @@ -15,7 +15,7 @@ public record CreateCouponRequest( @NotBlank(message = "쿠폰명이 입력되지 않았거나 20자를 넘었습니다.") @Length(max = 20) String name, @Length(max = 50, message = "쿠폰 간단 소개는 최대 50자까지 가능합니다.") String description, - @NotBlank(message = "쿠폰 종류를 입력해주세요.") String type, + @NotBlank(message = "쿠폰 종류를 입력해주세요.") String couponType, @Min(value = 1, message = "벌레 수 혹은 할인 금액은 1 이상이어야 합니다.") int point, @Min(value = 1, message = "쿠폰 재고는 1 이상이어야 합니다.") int stock, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index 06ca6cc5..561fee2c 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -1,35 +1,51 @@ 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.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.CouponService; +import com.moabam.api.dto.CouponResponse; +import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor -@RequestMapping("/admins") public class CouponController { private final CouponService couponService; - @PostMapping("/coupons") + @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) - public void createCoupon(@RequestBody CreateCouponRequest request) { + public void createCoupon(@Valid @RequestBody CreateCouponRequest request) { couponService.createCoupon(1L, request); } - @DeleteMapping("/coupons/{couponId}") + @DeleteMapping("/admins/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) public void deleteCoupon(@PathVariable Long couponId) { couponService.deleteCoupon(1L, couponId); } + + @GetMapping("/coupons/{couponId}") + @ResponseStatus(HttpStatus.OK) + public CouponResponse getCouponById(@PathVariable Long couponId) { + return couponService.getCouponById(couponId); + } + + @PostMapping("/coupons/search") + @ResponseStatus(HttpStatus.OK) + public List getCoupons(@Valid @RequestBody CouponSearchRequest request) { + return couponService.getCoupons(request); + } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 9d1b2145..56edb977 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -448,6 +448,7 @@

쿠폰(Coupon)

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

쿠폰 생성

@@ -460,17 +461,17 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 186
+Content-Length: 192
 Host: localhost:8080
 
 {
   "name" : "couponName",
   "description" : "coupon description",
-  "type" : "황금",
+  "couponType" : "황금",
   "point" : 10,
   "stock" : 10,
-  "startAt" : "2000-01-22T10:30",
-  "endAt" : "2000-02-22T11:00"
+  "startAt" : "2023-01-01T00:00",
+  "endAt" : "2023-02-01T00:00"
 }
@@ -489,9 +490,10 @@

응답

}
+
-

쿠폰 삭제 (진행 중)

+

쿠폰 삭제

관리자가 쿠폰 ID와 일치하는 쿠폰을 삭제합니다.
@@ -519,19 +521,88 @@

응답

}
+
-

쿠폰 조회 (진행 중)

+

특정 쿠폰 조회

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

요청

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

응답

+
+
+
HTTP/1.1 404 Not Found
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 56
+
+{
+  "message" : "존재하지 않는 쿠폰입니다."
+}
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

+
+
+
POST /coupons/search HTTP/1.1
+Content-Type: application/json;charset=UTF-8
+Content-Length: 84
+Host: localhost:8080
+
+{
+  "couponOngoing" : false,
+  "couponNotStarted" : false,
+  "couponEnded" : false
+}
+
+
+

응답

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 3
+
+[ ]
+
+
+
+
+
+
+

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

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

쿠폰 발급 (진행 중)

@@ -540,6 +611,7 @@

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

@@ -555,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 31c8fe45..206bb94a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/CouponServiceTest.java index 5cf985c5..d010f3b0 100644 --- a/src/test/java/com/moabam/api/application/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/CouponServiceTest.java @@ -3,11 +3,15 @@ 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; @@ -15,6 +19,9 @@ import com.moabam.api.domain.entity.Coupon; import com.moabam.api.domain.entity.enums.CouponType; import com.moabam.api.domain.repository.CouponRepository; +import com.moabam.api.domain.repository.CouponSearchRepository; +import com.moabam.api.dto.CouponResponse; +import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; @@ -31,6 +38,9 @@ class CouponServiceTest { @Mock private CouponRepository couponRepository; + @Mock + private CouponSearchRepository couponSearchRepository; + @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test void couponService_createCoupon() { @@ -114,4 +124,47 @@ void couponService_deleteCoupon_NotFoundException() { .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); } + + @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") + @Test + void couponService_getCouponById() { + // Given + Coupon coupon = CouponFixture.coupon(10, 100); + given(couponSearchRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); + + // When + CouponResponse actual = couponService.getCouponById(1L); + + // Then + assertThat(actual.point()).isEqualTo(coupon.getPoint()); + assertThat(actual.stock()).isEqualTo(coupon.getStock()); + } + + @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @Test + void couponService_getCouponById_NotFoundException() { + // Given + given(couponSearchRepository.findById(any(Long.class))).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponService.getCouponById(1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); + } + + @DisplayName("모든 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponService_getCoupons(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, true, true); + given(couponSearchRepository.findAllByStatus(any(LocalDateTime.class), any(CouponSearchRequest.class))) + .willReturn(coupons); + + // When + List actual = couponService.getCoupons(request); + + // Then + assertThat(actual).hasSize(coupons.size()); + } } diff --git a/src/test/java/com/moabam/api/domain/entity/CouponTest.java b/src/test/java/com/moabam/api/domain/entity/CouponTest.java index 1f68c7de..b517c83e 100644 --- a/src/test/java/com/moabam/api/domain/entity/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/entity/CouponTest.java @@ -25,7 +25,7 @@ void coupon() { Coupon actual = Coupon.builder() .name("couponName") .point(10) - .type(CouponType.MORNING_COUPON) + .couponType(CouponType.MORNING_COUPON) .stock(100) .startAt(startAt) .endAt(endAt) @@ -34,10 +34,10 @@ void coupon() { // Then assertThat(actual.getName()).isEqualTo("couponName"); - assertThat(actual.getDescription()).isNull(); + assertThat(actual.getDescription()).isBlank(); assertThat(actual.getPoint()).isEqualTo(10); assertThat(actual.getStock()).isEqualTo(100); - assertThat(actual.getType()).isEqualTo(CouponType.MORNING_COUPON); + assertThat(actual.getCouponType()).isEqualTo(CouponType.MORNING_COUPON); assertThat(actual.getStartAt()).isEqualTo(startAt); assertThat(actual.getEndAt()).isEqualTo(endAt); assertThat(actual.getAdminId()).isEqualTo(1L); diff --git a/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java new file mode 100644 index 00000000..d817de8a --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java @@ -0,0 +1,194 @@ +package com.moabam.api.domain.repository; + +import static org.assertj.core.api.Assertions.*; + +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.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; + +import com.moabam.api.domain.entity.Coupon; +import com.moabam.api.dto.CouponSearchRequest; +import com.moabam.global.config.JpaConfig; +import com.moabam.global.error.exception.NotFoundException; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.CouponFixture; + +@DataJpaTest +@Import({JpaConfig.class, CouponSearchRepository.class}) +class CouponSearchRepositoryTest { + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private CouponSearchRepository couponSearchRepository; + + @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") + @Test + void couponSearchRepository_findById() { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); + + // When + Coupon actual = couponSearchRepository.findById(coupon.getId()) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + + // Then + assertThat(actual.getStock()).isEqualTo(coupon.getStock()); + assertThat(actual.getPoint()).isEqualTo(coupon.getPoint()); + } + + @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @Test + void couponSearchRepository_findById_NotFoundException() { + // When + Optional actual = couponSearchRepository.findById(77777L); + + // Then + assertThat(actual).isEmpty(); + } + + @DisplayName("모든 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, true, true); + LocalDateTime now = LocalDateTime.now(); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(coupons.size()); + } + + @DisplayName("시작 전이거나 진행 중인 쿠폰들을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_and_ongoing_notStarted(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, true, false); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(8); + } + + @DisplayName("종료 됐거나 진행 중인 쿠폰들을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_and_ongoing_ended(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, false, true); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(5); + } + + @DisplayName("진행 중이 아니고, 시작 전이거나, 종료된 쿠폰들을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_ongoing_and_ended(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(false, true, true); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(7); + } + + @DisplayName("진행 중인 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_ongoing(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, false, false); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(3); + } + + @DisplayName("시작 적인 쿠폰들을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_notStarted(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(false, true, false); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(5); + } + + @DisplayName("종료된 쿠폰들을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus_ended(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(false, false, true); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).hasSize(2); + } + + @DisplayName("상태조건을 걸지 않아서 모든 쿠폰이 조회되지 않는다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponSearchRepository_findAllByStatus__not_status(List coupons) { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(false, false, false); + LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + + couponRepository.saveAll(coupons); + + // When + List actual = couponSearchRepository.findAllByStatus(now, request); + + // Then + assertThat(actual).isEmpty(); + } +} diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 5d1cdeae..819b149a 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -1,13 +1,18 @@ package com.moabam.api.presentation; +import static org.hamcrest.Matchers.*; 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.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; @@ -21,9 +26,12 @@ import com.moabam.api.domain.entity.enums.CouponType; import com.moabam.api.domain.repository.CouponRepository; import com.moabam.api.dto.CouponMapper; +import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; +import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.CouponFixture; import com.moabam.support.fixture.CouponSnippetFixture; +import com.moabam.support.fixture.ErrorSnippetFixture; @Transactional @SpringBootTest @@ -59,6 +67,28 @@ void couponController_createCoupon() throws Exception { .andExpect(status().isCreated()); } + @DisplayName("쿠폰 발급 종료기간 시작기간보다 이전인 쿠폰을 발행한다. - BadRequestException") + @Test + void couponController_createCoupon_BadRequestException() throws Exception { + // Given + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 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()), + CouponSnippetFixture.CREATE_COUPON_REQUEST, + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + } + @DisplayName("쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") @Test void couponController_createCoupon_ConflictException() throws Exception { @@ -75,8 +105,11 @@ void couponController_createCoupon_ConflictException() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST)) - .andExpect(status().isConflict()); + CouponSnippetFixture.CREATE_COUPON_REQUEST, + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isConflict()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_NAME.getMessage())); } @DisplayName("쿠폰을 성공적으로 삭제한다. - Void") @@ -102,7 +135,88 @@ void couponController_deleteCoupon_NotFoundException() throws Exception { .andDo(print()) .andDo(document("admins/coupons/couponId", preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()))) - .andExpect(status().isNotFound()); + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + } + + @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") + @Test + void couponController_getCouponById() 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()), + CouponSnippetFixture.COUPON_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.couponId").value(coupon.getId())); + } + + @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @Test + void couponController_getCouponById_NotFoundException() throws Exception { + // When & Then + mockMvc.perform(get("/coupons/77777777777")) + .andDo(print()) + .andDo(document("coupons/couponId", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + } + + @DisplayName("모든 쿠폰을 조회한다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponController_getCoupons(List coupons) throws Exception { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(true, true, true); + List coupon = couponRepository.saveAll(coupons); + + // 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()), + CouponSnippetFixture.COUPON_SEARCH_REQUEST, + CouponSnippetFixture.COUPON_SEARCH_RESPONSE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(coupon.size()))); + } + + @DisplayName("상태 조건을 걸지 않아서 쿠폰이 조회되지 않는다. - List") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") + @ParameterizedTest + void couponController_getCoupons_not_status(List coupons) throws Exception { + // Given + CouponSearchRequest request = CouponFixture.couponSearchRequest(false, false, false); + couponRepository.saveAll(coupons); + + // 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()), + CouponSnippetFixture.COUPON_SEARCH_REQUEST)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$", hasSize(0))); } } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 7d21071c..d2c848ac 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -17,6 +17,7 @@ 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; @@ -28,6 +29,8 @@ import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.domain.repository.RoomRepository; import com.moabam.global.common.repository.StringRedisRepository; +import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.ErrorSnippetFixture; import com.moabam.support.fixture.MemberFixture; import com.moabam.support.fixture.RoomFixture; @@ -99,8 +102,11 @@ void notificationController_sendKnockNotification_NotFoundException() throws Exc .andDo(print()) .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()))) - .andExpect(status().isNotFound()); + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage())); } @DisplayName("GET - 이미 콕 알림을 보낸 대상이다. - ConflictException") @@ -115,7 +121,10 @@ void notificationController_sendKnockNotification_ConflictException() throws Exc .andDo(print()) .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), - preprocessResponse(prettyPrint()))) - .andExpect(status().isConflict()); + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.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/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 6dbc68b8..47438806 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -1,9 +1,14 @@ package com.moabam.support.fixture; import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Stream; + +import org.junit.jupiter.params.provider.Arguments; import com.moabam.api.domain.entity.Coupon; import com.moabam.api.domain.entity.enums.CouponType; +import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; public final class CouponFixture { @@ -12,10 +17,22 @@ public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") .point(point) - .type(CouponType.MORNING_COUPON) + .couponType(CouponType.MORNING_COUPON) .stock(stock) - .startAt(LocalDateTime.of(2000, 1, 22, 10, 30, 0)) - .endAt(LocalDateTime.of(2000, 1, 22, 11, 0, 0)) + .startAt(LocalDateTime.of(2023, 1, 1, 0, 0)) + .endAt(LocalDateTime.of(2023, 1, 1, 0, 0)) + .adminId(1L) + .build(); + } + + public static Coupon coupon(String name, int startMonth, int endMonth) { + return Coupon.builder() + .name(name) + .point(10) + .couponType(CouponType.MORNING_COUPON) + .stock(100) + .startAt(LocalDateTime.of(2023, startMonth, 1, 0, 0)) + .endAt(LocalDateTime.of(2023, endMonth, 1, 0, 0)) .adminId(1L) .build(); } @@ -25,10 +42,35 @@ public static CreateCouponRequest createCouponRequest(String couponType, int sta .name("couponName") .description("coupon description") .point(10) - .type(couponType) + .couponType(couponType) .stock(10) - .startAt(LocalDateTime.of(2000, startMonth, 22, 10, 30, 0)) - .endAt(LocalDateTime.of(2000, endMonth, 22, 11, 0, 0)) + .startAt(LocalDateTime.of(2023, startMonth, 1, 0, 0)) + .endAt(LocalDateTime.of(2023, endMonth, 1, 0, 0)) + .build(); + } + + public static CouponSearchRequest couponSearchRequest(boolean ongoing, boolean notStarted, boolean ended) { + return CouponSearchRequest.builder() + .couponOngoing(ongoing) + .couponNotStarted(notStarted) + .couponEnded(ended) .build(); } + + public static Stream provideCoupons() { + return Stream.of(Arguments.of( + List.of( + coupon("coupon1", 1, 3), + coupon("coupon2", 2, 4), + coupon("coupon3", 3, 5), + coupon("coupon4", 4, 6), + coupon("coupon5", 5, 7), + coupon("coupon6", 6, 8), + coupon("coupon7", 7, 9), + coupon("coupon8", 8, 10), + coupon("coupon9", 9, 11), + coupon("coupon10", 10, 12) + )) + ); + } } diff --git a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java index 92345c0e..e263b158 100644 --- a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java @@ -4,16 +4,50 @@ 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 CouponSnippetFixture { 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("couponType").type(STRING).description("쿠폰 종류 (아침, 저녁, 황금, 할인)"), fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), fieldWithPath("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), fieldWithPath("endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") ); + + public static final ResponseFieldsSnippet COUPON_RESPONSE = responseFields( + fieldWithPath("couponId").type(NUMBER).description("쿠폰 ID"), + fieldWithPath("couponAdminName").type(STRING).description("쿠폰 관리자명"), + fieldWithPath("name").type(STRING).description("쿠폰명"), + fieldWithPath("description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), + fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), + fieldWithPath("couponType").type(STRING) + .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), + fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), + fieldWithPath("endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + ); + + public static final Snippet COUPON_SEARCH_REQUEST = requestFields( + fieldWithPath("couponOngoing").type(BOOLEAN).description("진행 상태 쿠폰 (true, false)"), + fieldWithPath("couponNotStarted").type(BOOLEAN).description("시작전 상태 쿠폰 (true, false)"), + fieldWithPath("couponEnded").type(BOOLEAN).description("종료 상태 쿠폰 (true, false)") + ); + + public static final ResponseFieldsSnippet COUPON_SEARCH_RESPONSE = responseFields( + fieldWithPath("[].couponId").type(NUMBER).description("쿠폰 ID"), + fieldWithPath("[].couponAdminName").type(STRING).description("쿠폰 관리자명"), + fieldWithPath("[].name").type(STRING).description("쿠폰명"), + fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), + fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), + fieldWithPath("[].stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), + fieldWithPath("[].couponType").type(STRING) + .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), + fieldWithPath("[].startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), + fieldWithPath("[].endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + ); } diff --git a/src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java b/src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java new file mode 100644 index 00000000..4c41f198 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java @@ -0,0 +1,13 @@ +package com.moabam.support.fixture; + +import static org.springframework.restdocs.payload.JsonFieldType.*; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; + +import org.springframework.restdocs.snippet.Snippet; + +public class ErrorSnippetFixture { + + public static final Snippet ERROR_MESSAGE_RESPONSE = responseFields( + fieldWithPath("message").type(STRING).description("에러 메시지") + ); +} From 0d084fa91439e68a63d98ce88355a5c1aa6397e6 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 13 Nov 2023 16:51:17 +0900 Subject: [PATCH 031/185] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20annotation?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=A0=9C=EA=B3=B5=20(#62)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원 엔티티 생성 및 테스트코드 추가 * feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩 * feat: authorization code를 받기 위한 queryString generator 추가 * feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가 * feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환 * refactor: member관련 클래스 네이밍과 폴더 위치 변경 * refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경 * style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제 * chore: config 파일 업데이트 * refactor: 테스트 코드 추가 및 코드 포맷 재적용 * refactor: 사용하지 않는 코드 제거 * refactor: CRLF -> LF로 변경 * fix: config 커밋, config 최근 커밋으로 변경 * feat: 테스트 코드 추가 및 패키지 구조 변경 * refactor: revert merge * fix: merge confilt해결 및 예외처리 추가 * test: oauth properties가 없을 때의 테스트코드 추가 * feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경 * fix: 테스트코드 관련 code smell 제거 * feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가 * feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가 * refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리 * test: 로그인 요청 테스트 코드 추가 * feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경 * test: restTemplate 및 서비스 테스트 추가 * refactor: 에러 메세지 이름 변경 * refacotr: 변수명 및 entity default 명 변경 * feat: 토큰 정보 조회 기능 및 테스트 추가 * feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경 * fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성 * feat: JWT 토큰 제공 서비스 및 테스트 코드 추가 * feat: 토큰 인증 코드 및 테스트 코드 작성 * feat: 로그인 및 회원가입 기능 추가 - 회원의 socialId string -> long으로 변경 * feat: 회원 로그인 테스트 코드 추가 * chore: 코드 포메팅 재 설정 * feat: config 파일 업데이트 * feat: Window용 포트 redis 포트 변경 추가 * refacotr: develop 업데이트 사항 merge * refactor: develop 업데이트 부분 merge * fix: TimeConfig 삭제 및 코드 스멜 변경 * refactor: 코르리뷰 반영 * chore: submodule update * feat: 메서드 파싱 customizing 및 @CurrentMember AuthorizationMember 를 파라미터로 감지하는 조건 추가 * feat: 인가회원에 대한 객체 ThreadLocalMap에 저장하는 기능 추가 * fix: 회원 정보 Optional 정보 조회 버그 fix, socialId requiredNotNull추가 등 에러 수정 * feat: API요청 Path 및 인증에 따른 filter 추가 - PathFilter: PathResolver, WebConfig - AuthorizationFilter:AuthorizationService, JwtAuthenticationService, JwtProviderService, MemberService - Member info: CurrentMember, AuthorizationMember, LoginResponse, MemberMapper, CurrentMember, PublicClaim, CurrentMemberArgumentResolver * test: CurrentMember 테스트 support 추가 * test: authorizationfilter 및 pathfilter 테스트 추가 * test: 회원 repostiory 및 fixture 추가 * test: filter support 클랠스 추가 * test: filter support 클래스 적용 * refactor: PublicClaim 변환 책임 변경 * test: PathResolver, CurrentMemberArgumentResovler테스트 코드 추가 * fix: 모든 쿠키 secure 적용되도록 변경 * refactor: 클래스 명 변경 * refactor: webConfig Path 매핑 클래스 추가 --- ...Service.java => AuthorizationService.java} | 27 +- .../application/JwtAuthenticationService.java | 16 +- .../api/application/JwtProviderService.java | 28 +- .../moabam/api/application/MemberService.java | 6 +- .../com/moabam/api/domain/entity/Member.java | 2 +- ...thMapper.java => AuthorizationMapper.java} | 17 +- .../moabam/api/dto/AuthorizationMember.java | 11 + .../com/moabam/api/dto/LoginResponse.java | 6 +- .../java/com/moabam/api/dto/MemberMapper.java | 14 +- .../java/com/moabam/api/dto/PathMapper.java | 43 + .../java/com/moabam/api/dto/PublicClaim.java | 15 + .../api/presentation/MemberController.java | 12 +- .../common/annotation/CurrentMember.java | 12 + .../CurrentMemberArgumentResolver.java | 31 + .../global/common/handler/PathResolver.java | 85 + .../common/util/AuthorizationThreadLocal.java | 28 + .../global/common/util/CookieUtils.java | 11 +- .../com/moabam/global/config/WebConfig.java | 30 + .../global/error/model/ErrorMessage.java | 2 +- .../global/filter/AuthorizationFilter.java | 113 + .../com/moabam/global/filter/PathFilter.java | 39 + src/main/resources/static/docs/coupon.html | 2791 +++++++++++++---- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2605 ++++++++++++--- ...est.java => AuthorizationServiceTest.java} | 51 +- .../JwtAuthenticationServiceTest.java | 47 +- .../application/JwtProviderServiceTest.java | 13 +- .../api/application/MemberServiceTest.java | 4 +- .../repository/MemberRepositoryTest.java | 32 + .../api/presentation/BugControllerTest.java | 5 +- .../presentation/CouponControllerTest.java | 3 +- .../api/presentation/ItemControllerTest.java | 23 +- .../presentation/MemberControllerTest.java | 8 +- .../NotificationControllerTest.java | 3 +- .../presentation/ProductControllerTest.java | 5 +- .../api/presentation/RoomControllerTest.java | 5 +- .../CurrentMemberArgumentResolverTest.java | 115 + .../common/handler/PathResolverTest.java | 65 + .../filter/AuthorizationFilterTest.java | 174 + .../moabam/global/filter/PathFilterTest.java | 76 + .../moabam/support/annotation/WithMember.java | 19 + .../common/FilterProcessExtension.java | 61 + .../support/common/RestDocsFactory.java | 31 + .../support/common/WithFilterSupporter.java | 51 + .../common/WithoutFilterSupporter.java | 37 + .../support/fixture/JwtProviderFixture.java | 19 + .../support/fixture/PublicClaimFixture.java | 15 + src/test/resources/application.yml | 1 + 48 files changed, 5682 insertions(+), 1127 deletions(-) rename src/main/java/com/moabam/api/application/{AuthenticationService.java => AuthorizationService.java} (81%) rename src/main/java/com/moabam/api/dto/{OAuthMapper.java => AuthorizationMapper.java} (60%) create mode 100644 src/main/java/com/moabam/api/dto/AuthorizationMember.java create mode 100644 src/main/java/com/moabam/api/dto/PathMapper.java create mode 100644 src/main/java/com/moabam/api/dto/PublicClaim.java create mode 100644 src/main/java/com/moabam/global/common/annotation/CurrentMember.java create mode 100644 src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java create mode 100644 src/main/java/com/moabam/global/common/handler/PathResolver.java create mode 100644 src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java create mode 100644 src/main/java/com/moabam/global/filter/AuthorizationFilter.java create mode 100644 src/main/java/com/moabam/global/filter/PathFilter.java rename src/test/java/com/moabam/api/application/{AuthenticationServiceTest.java => AuthorizationServiceTest.java} (77%) create mode 100644 src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java create mode 100644 src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java create mode 100644 src/test/java/com/moabam/global/common/handler/PathResolverTest.java create mode 100644 src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java create mode 100644 src/test/java/com/moabam/global/filter/PathFilterTest.java create mode 100644 src/test/java/com/moabam/support/annotation/WithMember.java create mode 100644 src/test/java/com/moabam/support/common/FilterProcessExtension.java create mode 100644 src/test/java/com/moabam/support/common/RestDocsFactory.java create mode 100644 src/test/java/com/moabam/support/common/WithFilterSupporter.java create mode 100644 src/test/java/com/moabam/support/common/WithoutFilterSupporter.java create mode 100644 src/test/java/com/moabam/support/fixture/JwtProviderFixture.java create mode 100644 src/test/java/com/moabam/support/fixture/PublicClaimFixture.java diff --git a/src/main/java/com/moabam/api/application/AuthenticationService.java b/src/main/java/com/moabam/api/application/AuthorizationService.java similarity index 81% rename from src/main/java/com/moabam/api/application/AuthenticationService.java rename to src/main/java/com/moabam/api/application/AuthorizationService.java index 384d4caa..8f2db0e3 100644 --- a/src/main/java/com/moabam/api/application/AuthenticationService.java +++ b/src/main/java/com/moabam/api/application/AuthorizationService.java @@ -9,14 +9,16 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationMapper; import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.LoginResponse; -import com.moabam.api.dto.OAuthMapper; +import com.moabam.api.dto.PublicClaim; import com.moabam.global.common.util.CookieUtils; import com.moabam.global.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; +import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -25,9 +27,10 @@ @Service @RequiredArgsConstructor -public class AuthenticationService { +public class AuthorizationService { private final OAuthConfig oAuthConfig; + private final TokenConfig tokenConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; private final MemberService memberService; private final JwtProviderService jwtProviderService; @@ -55,13 +58,13 @@ public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenRespons public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { LoginResponse loginResponse = memberService.login(authorizationTokenInfoResponse); - issueServiceToken(httpServletResponse, loginResponse.id()); + issueServiceToken(httpServletResponse, loginResponse.publicClaim()); return loginResponse; } private String getAuthorizationCodeUri() { - AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oAuthConfig); + AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); } @@ -91,7 +94,8 @@ private void validAuthorizationGrant(String code) { } private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code) { - AuthorizationTokenRequest authorizationTokenRequest = OAuthMapper.toAuthorizationTokenRequest(oAuthConfig, + AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( + oAuthConfig, code); MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest); ResponseEntity authorizationTokenResponse = @@ -115,9 +119,14 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq return contents; } - private void issueServiceToken(HttpServletResponse response, Long id) { - response.addHeader("token_type", "Bearer"); - response.addCookie(CookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(id))); - response.addCookie(CookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(id))); + public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) { + response.addCookie( + CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); + response.addCookie( + CookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(publicClaim), + tokenConfig.getRefreshExpire())); + response.addCookie( + CookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(), + tokenConfig.getRefreshExpire())); } } diff --git a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java index 14247dab..4956e397 100644 --- a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java +++ b/src/main/java/com/moabam/api/application/JwtAuthenticationService.java @@ -5,6 +5,8 @@ import org.json.JSONObject; import org.springframework.stereotype.Service; +import com.moabam.api.dto.AuthorizationMapper; +import com.moabam.api.dto.PublicClaim; import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.UnauthorizedException; import com.moabam.global.error.model.ErrorMessage; @@ -19,25 +21,25 @@ public class JwtAuthenticationService { private final TokenConfig tokenConfig; - public boolean isTokenValid(String token) { + public boolean isTokenExpire(String token) { try { Jwts.parserBuilder() .setSigningKey(tokenConfig.getKey()) .build() - .parseClaimsJwt(token); - return true; - } catch (ExpiredJwtException expiredJwtException) { + .parseClaimsJws(token); return false; + } catch (ExpiredJwtException expiredJwtException) { + return true; } catch (Exception exception) { - throw new UnauthorizedException(ErrorMessage.AUTHENTICATIE_FAIL); + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); } } - public String parseEmail(String token) { + public PublicClaim parseClaim(String token) { String claims = token.split("\\.")[1]; String decodeClaims = new String(Base64.getDecoder().decode(claims)); JSONObject jsonObject = new JSONObject(decodeClaims); - return (String)jsonObject.get("id"); + return AuthorizationMapper.toPublicClaim(jsonObject); } } diff --git a/src/main/java/com/moabam/api/application/JwtProviderService.java b/src/main/java/com/moabam/api/application/JwtProviderService.java index 50466841..1ea2735b 100644 --- a/src/main/java/com/moabam/api/application/JwtProviderService.java +++ b/src/main/java/com/moabam/api/application/JwtProviderService.java @@ -4,8 +4,10 @@ import org.springframework.stereotype.Service; +import com.moabam.api.dto.PublicClaim; import com.moabam.global.config.TokenConfig; +import io.jsonwebtoken.JwtBuilder; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import lombok.RequiredArgsConstructor; @@ -16,15 +18,27 @@ public class JwtProviderService { private final TokenConfig tokenConfig; - public String provideAccessToken(long id) { - return generateToken(id, tokenConfig.getAccessExpire()); + public String provideAccessToken(PublicClaim publicClaim) { + return generateIdToken(publicClaim, tokenConfig.getAccessExpire()); } - public String provideRefreshToken(long id) { - return generateToken(id, tokenConfig.getRefreshExpire()); + public String provideRefreshToken() { + return generateCommonInfo(tokenConfig.getRefreshExpire()); } - private String generateToken(long id, long expireTime) { + private String generateIdToken(PublicClaim publicClaim, long expireTime) { + return commonInfo(expireTime) + .claim("id", publicClaim.id()) + .claim("nickname", publicClaim.nickname()) + .claim("role", publicClaim.role()) + .compact(); + } + + private String generateCommonInfo(long expireTime) { + return commonInfo(expireTime).compact(); + } + + private JwtBuilder commonInfo(long expireTime) { Date issueDate = new Date(); Date expireDate = new Date(issueDate.getTime() + expireTime); @@ -34,8 +48,6 @@ private String generateToken(long id, long expireTime) { .setIssuer(tokenConfig.getIss()) .setIssuedAt(issueDate) .setExpiration(expireDate) - .claim("id", id) - .signWith(tokenConfig.getKey(), SignatureAlgorithm.HS256) - .compact(); + .signWith(tokenConfig.getKey(), SignatureAlgorithm.HS256); } } diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/MemberService.java index f3609251..ca37cdea 100644 --- a/src/main/java/com/moabam/api/application/MemberService.java +++ b/src/main/java/com/moabam/api/application/MemberService.java @@ -13,7 +13,6 @@ import com.moabam.api.domain.entity.Member; import com.moabam.api.domain.repository.MemberRepository; import com.moabam.api.domain.repository.MemberSearchRepository; -import com.moabam.api.domain.repository.NotificationRepository; import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.LoginResponse; import com.moabam.api.dto.MemberMapper; @@ -28,7 +27,6 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberSearchRepository memberSearchRepository; - private final NotificationRepository notificationRepository; public Member getById(Long memberId) { return memberRepository.findById(memberId) @@ -38,9 +36,9 @@ public Member getById(Long memberId) { @Transactional public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { Optional member = memberRepository.findBySocialId(authorizationTokenInfoResponse.id()); - Member loginMember = member.orElse(signUp(authorizationTokenInfoResponse.id())); + Member loginMember = member.orElseGet(() -> signUp(authorizationTokenInfoResponse.id())); - return MemberMapper.toLoginResponse(loginMember.getId(), member.isEmpty()); + return MemberMapper.toLoginResponse(loginMember, member.isEmpty()); } private Member signUp(Long socialId) { diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index fb866e07..13029124 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -82,7 +82,7 @@ public class Member extends BaseTimeEntity { @Builder private Member(Long id, Long socialId, String nickname, Bug bug) { this.id = id; - this.socialId = socialId; + this.socialId = requireNonNull(socialId); this.nickname = requireNonNull(nickname); this.profileImage = BaseImageUrl.PROFILE_URL; this.bug = requireNonNull(bug); diff --git a/src/main/java/com/moabam/api/dto/OAuthMapper.java b/src/main/java/com/moabam/api/dto/AuthorizationMapper.java similarity index 60% rename from src/main/java/com/moabam/api/dto/OAuthMapper.java rename to src/main/java/com/moabam/api/dto/AuthorizationMapper.java index a637ff4e..8b6ceeae 100644 --- a/src/main/java/com/moabam/api/dto/OAuthMapper.java +++ b/src/main/java/com/moabam/api/dto/AuthorizationMapper.java @@ -1,12 +1,15 @@ package com.moabam.api.dto; +import org.json.JSONObject; + +import com.moabam.api.domain.entity.enums.Role; import com.moabam.global.config.OAuthConfig; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class OAuthMapper { +public final class AuthorizationMapper { public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oAuthConfig) { return AuthorizationCodeRequest.builder() @@ -25,4 +28,16 @@ public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig .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 AuthorizationMember toAuthorizationMember(PublicClaim publicClaim) { + return new AuthorizationMember(publicClaim.id(), publicClaim.nickname(), publicClaim.role()); + } } diff --git a/src/main/java/com/moabam/api/dto/AuthorizationMember.java b/src/main/java/com/moabam/api/dto/AuthorizationMember.java new file mode 100644 index 00000000..2924d307 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/AuthorizationMember.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import com.moabam.api.domain.entity.enums.Role; + +public record AuthorizationMember( + Long id, + String nickname, + Role role +) { + +} diff --git a/src/main/java/com/moabam/api/dto/LoginResponse.java b/src/main/java/com/moabam/api/dto/LoginResponse.java index 49e13cc9..4d22458e 100644 --- a/src/main/java/com/moabam/api/dto/LoginResponse.java +++ b/src/main/java/com/moabam/api/dto/LoginResponse.java @@ -1,11 +1,13 @@ package com.moabam.api.dto; +import com.fasterxml.jackson.annotation.JsonUnwrapped; + import lombok.Builder; @Builder public record LoginResponse( - Long id, - boolean isSignUp + boolean isSignUp, + @JsonUnwrapped PublicClaim publicClaim ) { } diff --git a/src/main/java/com/moabam/api/dto/MemberMapper.java b/src/main/java/com/moabam/api/dto/MemberMapper.java index 2802b2ba..84646532 100644 --- a/src/main/java/com/moabam/api/dto/MemberMapper.java +++ b/src/main/java/com/moabam/api/dto/MemberMapper.java @@ -17,15 +17,13 @@ public static Member toMember(Long socialId, String nickName) { .build(); } - public static LoginResponse toLoginResponse(Long memberId) { + public static LoginResponse toLoginResponse(Member member, boolean isSignUp) { return LoginResponse.builder() - .id(memberId) - .build(); - } - - public static LoginResponse toLoginResponse(Long memberId, boolean isSignUp) { - return LoginResponse.builder() - .id(memberId) + .publicClaim(PublicClaim.builder() + .id(member.getId()) + .nickname(member.getNickname()) + .role(member.getRole()) + .build()) .isSignUp(isSignUp) .build(); } diff --git a/src/main/java/com/moabam/api/dto/PathMapper.java b/src/main/java/com/moabam/api/dto/PathMapper.java new file mode 100644 index 00000000..941fc731 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/PathMapper.java @@ -0,0 +1,43 @@ +package com.moabam.api.dto; + +import static java.util.Objects.*; + +import java.util.List; + +import org.springframework.http.HttpMethod; + +import com.moabam.api.domain.entity.enums.Role; +import com.moabam.global.common.handler.PathResolver; + +import jakarta.annotation.Nonnull; +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 parsePath(String uri, @Nonnull List params) { + if (!params.isEmpty() && params.get(0) instanceof Role) { + return parsePath(uri, (List)params, null); + } + return parsePath(uri, null, (List)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/dto/PublicClaim.java b/src/main/java/com/moabam/api/dto/PublicClaim.java new file mode 100644 index 00000000..e67c4fc5 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/PublicClaim.java @@ -0,0 +1,15 @@ +package com.moabam.api.dto; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.moabam.api.domain.entity.enums.Role; + +import lombok.Builder; + +@Builder +public record PublicClaim( + Long id, + @JsonIgnore String nickname, + @JsonIgnore Role role +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 20ec8018..f7a96591 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -7,7 +7,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import com.moabam.api.application.AuthenticationService; +import com.moabam.api.application.AuthorizationService; import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenResponse; @@ -21,21 +21,21 @@ @RequiredArgsConstructor public class MemberController { - private final AuthenticationService authenticationService; + private final AuthorizationService authorizationService; @GetMapping public void socialLogin(HttpServletResponse httpServletResponse) { - authenticationService.redirectToLoginPage(httpServletResponse); + authorizationService.redirectToLoginPage(httpServletResponse); } @GetMapping("/login/kakao/oauth") @ResponseStatus(HttpStatus.OK) public LoginResponse authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse, HttpServletResponse httpServletResponse) { - AuthorizationTokenResponse tokenResponse = authenticationService.requestToken(authorizationCodeResponse); + AuthorizationTokenResponse tokenResponse = authorizationService.requestToken(authorizationCodeResponse); AuthorizationTokenInfoResponse authorizationTokenInfoResponse = - authenticationService.requestTokenInfo(tokenResponse); + authorizationService.requestTokenInfo(tokenResponse); - return authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + return authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); } } diff --git a/src/main/java/com/moabam/global/common/annotation/CurrentMember.java b/src/main/java/com/moabam/global/common/annotation/CurrentMember.java new file mode 100644 index 00000000..2d094ced --- /dev/null +++ b/src/main/java/com/moabam/global/common/annotation/CurrentMember.java @@ -0,0 +1,12 @@ +package com.moabam.global.common.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 CurrentMember { + +} diff --git a/src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java b/src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java new file mode 100644 index 00000000..1d0d9760 --- /dev/null +++ b/src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java @@ -0,0 +1,31 @@ +package com.moabam.global.common.handler; + +import static com.moabam.global.common.util.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.api.dto.AuthorizationMember; +import com.moabam.global.common.annotation.CurrentMember; + +public class CurrentMemberArgumentResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return Objects.nonNull(parameter.getParameterAnnotation(CurrentMember.class)) + && parameter.getParameterType().equals(AuthorizationMember.class); + } + + @Override + public Object resolveArgument(@Nullable MethodParameter parameter, ModelAndViewContainer mavContainer, + @Nullable NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + return getAuthorizationMember(); + } +} diff --git a/src/main/java/com/moabam/global/common/handler/PathResolver.java b/src/main/java/com/moabam/global/common/handler/PathResolver.java new file mode 100644 index 00000000..76ab8cb4 --- /dev/null +++ b/src/main/java/com/moabam/global/common/handler/PathResolver.java @@ -0,0 +1,85 @@ +package com.moabam.global.common.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.entity.enums.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/common/util/AuthorizationThreadLocal.java b/src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java new file mode 100644 index 00000000..0174b764 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java @@ -0,0 +1,28 @@ +package com.moabam.global.common.util; + +import com.moabam.api.dto.AuthorizationMember; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AuthorizationThreadLocal { + + private static final ThreadLocal authorizationMember; + + static { + authorizationMember = new ThreadLocal<>(); + } + + public static void setAuthorizationMember(AuthorizationMember authorizationMember) { + AuthorizationThreadLocal.authorizationMember.set(authorizationMember); + } + + public static AuthorizationMember getAuthorizationMember() { + return authorizationMember.get(); + } + + public static void remove() { + authorizationMember.remove(); + } +} diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index b7ece395..c1220718 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -7,11 +7,20 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CookieUtils { - public static Cookie tokenCookie(String name, String value) { + public static Cookie tokenCookie(String name, String value, long expireTime) { + return basic(name, value, expireTime); + } + + public static Cookie typeCookie(String value, long expireTime) { + return basic("token_type", value, expireTime); + } + + private static Cookie basic(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/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index 2f276c96..166265d5 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -1,9 +1,17 @@ package com.moabam.global.config; +import java.util.List; + +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import com.moabam.api.dto.PathMapper; +import com.moabam.global.common.handler.CurrentMemberArgumentResolver; +import com.moabam.global.common.handler.PathResolver; + @Configuration public class WebConfig implements WebMvcConfigurer { @@ -20,4 +28,26 @@ public void addCorsMappings(final CorsRegistry registry) { .allowCredentials(true) .maxAge(3600); } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(handlerMethodArgumentResolver()); + } + + @Bean + public HandlerMethodArgumentResolver handlerMethodArgumentResolver() { + return new CurrentMemberArgumentResolver(); + } + + @Bean + public PathResolver pathResolver() { + PathResolver.Paths path = PathResolver.Paths.builder() + .permitAll(List.of( + PathMapper.parsePath("/members"), + PathMapper.parsePath("/members/login/*/oauth") + )) + .build(); + + return new PathResolver(path); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 8efa2059..6a9904f6 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -26,7 +26,7 @@ public enum ErrorMessage { LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), GRANT_FAILED("인가 코드 실패"), - AUTHENTICATIE_FAIL("인증 실패"), + AUTHENTICATE_FAIL("인증 실패"), MEMBER_NOT_FOUND("존재하지 않는 회원입니다."), MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), diff --git a/src/main/java/com/moabam/global/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/filter/AuthorizationFilter.java new file mode 100644 index 00000000..5aeea6fc --- /dev/null +++ b/src/main/java/com/moabam/global/filter/AuthorizationFilter.java @@ -0,0 +1,113 @@ +package com.moabam.global.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.AuthorizationService; +import com.moabam.api.application.JwtAuthenticationService; +import com.moabam.api.dto.AuthorizationMapper; +import com.moabam.api.dto.PublicClaim; +import com.moabam.global.common.util.AuthorizationThreadLocal; +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) { + log.error("Login Failed"); + 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.GRANT_FAILED); + } + + handleTokenAuthenticate(cookies, httpServletResponse); + } + + private boolean isTokenTypeBearer(Cookie[] cookies) { + return "Bearer".equals(extractTokenFromCookie(cookies, "token_type")); + } + + private void handleTokenAuthenticate(Cookie[] cookies, + HttpServletResponse httpServletResponse) { + String accessToken = extractTokenFromCookie(cookies, "access_token"); + PublicClaim publicClaim = authenticationService.parseClaim(accessToken); + + if (authenticationService.isTokenExpire(accessToken)) { + String refreshToken = extractTokenFromCookie(cookies, "refresh_token"); + + if (authenticationService.isTokenExpire(refreshToken)) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + + authorizationService.issueServiceToken(httpServletResponse, publicClaim); + } + + AuthorizationThreadLocal.setAuthorizationMember(AuthorizationMapper.toAuthorizationMember(publicClaim)); + } + + private Cookie[] getCookiesOrThrow(HttpServletRequest httpServletRequest) { + return Optional.ofNullable(httpServletRequest.getCookies()) + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.GRANT_FAILED)); + } + + 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.AUTHENTICATE_FAIL)); + } +} diff --git a/src/main/java/com/moabam/global/filter/PathFilter.java b/src/main/java/com/moabam/global/filter/PathFilter.java new file mode 100644 index 00000000..4baf8aa9 --- /dev/null +++ b/src/main/java/com/moabam/global/filter/PathFilter.java @@ -0,0 +1,39 @@ +package com.moabam.global.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.common.handler.PathResolver; + +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 + protected 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); + } + }); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 56edb977..b3b98263 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,467 +1,2142 @@ - - - - -쿠폰(Coupon) - - + + + + + 쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
+<<<<<<< HEAD
+Content-Length: 194
+=======
 Content-Length: 192
+>>>>>>> b5f9a450e8eb3b4f4527d412d519e6afc9c16365
 Host: localhost:8080
 
 {
@@ -473,74 +2148,74 @@ 

요청

"startAt" : "2023-01-01T00:00", "endAt" : "2023-02-01T00:00" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 62
+Content-Length: 64
 
 {
   "message" : "쿠폰의 이름이 중복되었습니다."
 }
-
-
-
-
-
-

쿠폰 삭제

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

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/77777777777 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 56
+Content-Length: 58
 
 {
   "message" : "존재하지 않는 쿠폰입니다."
 }
-
-
-
-
-
-

특정 쿠폰 조회

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

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -551,22 +2226,22 @@ 

응답

{ "message" : "존재하지 않는 쿠폰입니다." }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

+
+
POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 84
@@ -577,11 +2252,11 @@ 

요청

"couponNotStarted" : false, "couponEnded" : false }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -590,45 +2265,45 @@ 

응답

Content-Length: 3 [ ]
-
-
-
-
-
-
-

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

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

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

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

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

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

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 62f80fd6..3e40058e 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 206bb94a..b82bdbff 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -1,494 +1,2165 @@ - - - - -알림(Notification) - - + + + + + 알림(Notification) + +
-
-

알림(Notification)

-
-
-
-
콕 찌르기 알림 기능을 제공합니다.
-
-
-
-

콕 찌르기 알림

-
-
+
+

알림(Notification)

+
+
+
+
콕 찌르기 알림 기능을 제공합니다.
+
+
+
+

콕 찌르기 알림

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

요청

-
-
+
+
+

요청

+
+
GET /notifications/rooms/3/members/3 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 64
+Content-Length: 66
 
 {
   "message" : "이미 콕 알림을 보낸 대상입니다."
 }
-
-
-
-
-
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java similarity index 77% rename from src/test/java/com/moabam/api/application/AuthenticationServiceTest.java rename to src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index 57afb71a..e83f0e43 100644 --- a/src/test/java/com/moabam/api/application/AuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -24,12 +24,14 @@ import com.moabam.api.dto.AuthorizationCodeRequest; import com.moabam.api.dto.AuthorizationCodeResponse; +import com.moabam.api.dto.AuthorizationMapper; import com.moabam.api.dto.AuthorizationTokenInfoResponse; import com.moabam.api.dto.AuthorizationTokenRequest; import com.moabam.api.dto.AuthorizationTokenResponse; import com.moabam.api.dto.LoginResponse; -import com.moabam.api.dto.OAuthMapper; +import com.moabam.api.dto.PublicClaim; import com.moabam.global.config.OAuthConfig; +import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.AuthorizationResponseFixture; @@ -37,10 +39,10 @@ import jakarta.servlet.http.Cookie; @ExtendWith(MockitoExtension.class) -class AuthenticationServiceTest { +class AuthorizationServiceTest { @InjectMocks - AuthenticationService authenticationService; + AuthorizationService authorizationService; @Mock OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; @@ -52,24 +54,30 @@ class AuthenticationServiceTest { JwtProviderService jwtProviderService; OAuthConfig oauthConfig; - AuthenticationService noPropertyService; + TokenConfig tokenConfig; + AuthorizationService noPropertyService; OAuthConfig noOAuthConfig; @BeforeEach public void initParams() { + tokenConfig = new TokenConfig(null, 100000, 150000, + "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"); + ReflectionTestUtils.setField(authorizationService, "tokenConfig", tokenConfig); + oauthConfig = new OAuthConfig( new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", "http://tokenInfo/url"), new OAuthConfig.Client("provider", "testtestetsttest", "testtesttest", "authorization_code", List.of("profile_nickname", "profile_image")) ); - ReflectionTestUtils.setField(authenticationService, "oAuthConfig", oauthConfig); + ReflectionTestUtils.setField(authorizationService, "oAuthConfig", oauthConfig); noOAuthConfig = new OAuthConfig( new OAuthConfig.Provider(null, null, null, null), new OAuthConfig.Client(null, null, null, null, null) ); - noPropertyService = new AuthenticationService(noOAuthConfig, oAuth2AuthorizationServerRequestService, + noPropertyService = new AuthorizationService(noOAuthConfig, tokenConfig, + oAuth2AuthorizationServerRequestService, memberService, jwtProviderService); } @@ -77,7 +85,7 @@ public void initParams() { @Test void authorization_code_request_mapping_fail() { // When + Then - Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationCodeRequest(noOAuthConfig)) + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationCodeRequest(noOAuthConfig)) .isInstanceOf(NullPointerException.class); } @@ -85,7 +93,7 @@ void authorization_code_request_mapping_fail() { @Test void authorization_code_request_mapping_success() { // Given - AuthorizationCodeRequest authorizationCodeRequest = OAuthMapper.toAuthorizationCodeRequest(oauthConfig); + AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oauthConfig); // When + Then assertThat(authorizationCodeRequest).isNotNull(); @@ -102,7 +110,7 @@ void redirect_loginPage_success() { MockHttpServletResponse mockHttpServletResponse = new MockHttpServletResponse(); // when - authenticationService.redirectToLoginPage(mockHttpServletResponse); + authorizationService.redirectToLoginPage(mockHttpServletResponse); // then verify(oAuth2AuthorizationServerRequestService).loginRequest(eq(mockHttpServletResponse), anyString()); @@ -116,7 +124,7 @@ void authorization_grant_fail() { "errorDescription", null); // When + Then - assertThatThrownBy(() -> authenticationService.requestToken(authorizationCodeResponse)) + assertThatThrownBy(() -> authorizationService.requestToken(authorizationCodeResponse)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.GRANT_FAILED.getMessage()); } @@ -135,14 +143,14 @@ void authorization_grant_success() { new ResponseEntity<>(authorizationTokenResponse, HttpStatus.OK)); // When + Then - assertThatNoException().isThrownBy(() -> authenticationService.requestToken(authorizationCodeResponse)); + assertThatNoException().isThrownBy(() -> authorizationService.requestToken(authorizationCodeResponse)); } @DisplayName("토큰 요청 매퍼 실패 - code null") @Test void token_request_mapping_failBy_code() { // When + Then - Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationTokenRequest(oauthConfig, null)) + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(oauthConfig, null)) .isInstanceOf(NullPointerException.class); } @@ -150,7 +158,7 @@ void token_request_mapping_failBy_code() { @Test void token_request_mapping_failBy_config() { // When + Then - Assertions.assertThatThrownBy(() -> OAuthMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test")) + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test")) .isInstanceOf(NullPointerException.class); } @@ -159,7 +167,8 @@ void token_request_mapping_failBy_config() { void token_request_mapping_success() { // Given String code = "Test"; - AuthorizationTokenRequest authorizationTokenRequest = OAuthMapper.toAuthorizationTokenRequest(oauthConfig, + AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( + oauthConfig, code); // When + Then @@ -186,7 +195,7 @@ void generate_token() { .thenReturn(new ResponseEntity<>(tokenInfoResponse, HttpStatus.OK)); // Then - assertThatNoException().isThrownBy(() -> authenticationService.requestTokenInfo(tokenResponse)); + assertThatNoException().isThrownBy(() -> authorizationService.requestTokenInfo(tokenResponse)); } @DisplayName("회원 가입 및 로그인 성공 테스트") @@ -198,7 +207,10 @@ void signUp_success(boolean isSignUp) { AuthorizationTokenInfoResponse authorizationTokenInfoResponse = AuthorizationResponseFixture.authorizationTokenInfoResponse(); LoginResponse loginResponse = LoginResponse.builder() - .id(1L) + .publicClaim(PublicClaim.builder() + .id(1L) + .nickname("nickname") + .build()) .isSignUp(isSignUp) .build(); @@ -206,11 +218,14 @@ void signUp_success(boolean isSignUp) { // when LoginResponse result = - authenticationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); // then assertThat(loginResponse).isEqualTo(result); - assertThat(httpServletResponse.getHeader("token_type")).isEqualTo("Bearer"); + + Cookie tokenType = httpServletResponse.getCookie("token_type"); + assertThat(tokenType).isNotNull(); + assertThat(tokenType.getValue()).isEqualTo("Bearer"); Cookie accessCookie = httpServletResponse.getCookie("access_token"); assertThat(accessCookie).isNotNull(); diff --git a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java index 1c4df207..addbd4fd 100644 --- a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java @@ -1,6 +1,7 @@ package com.moabam.api.application; import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; import java.nio.charset.StandardCharsets; import java.security.Key; @@ -15,8 +16,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.dto.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; @@ -42,21 +45,33 @@ void initConfig() { jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); } + @DisplayName("토큰 인증 성공 테스트") + @Test + void token_authentication_success() { + // given + String token = jwtProviderService.provideAccessToken(PublicClaimFixture.publicClaim()); + + // when, then + assertThatNoException().isThrownBy(() -> + jwtAuthenticationService.isTokenExpire(token)); + } + @DisplayName("토큰 인증 시간 만료 테스트") @Test void token_authentication_time_expire() { // Given + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); TokenConfig tokenConfig = new TokenConfig(originIss, 0, 0, originSecretKey); JwtAuthenticationService jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); - String token = jwtProviderService.provideAccessToken(originId); + String token = jwtProviderService.provideAccessToken(publicClaim); // When assertThatNoException().isThrownBy(() -> { - boolean result = jwtAuthenticationService.isTokenValid(token); + boolean result = jwtAuthenticationService.isTokenExpire(token); // Then - assertThat(result).isFalse(); + assertThat(result).isTrue(); }); } @@ -64,7 +79,9 @@ void token_authentication_time_expire() { @Test void token_authenticate_failBy_payload() { // Given - String token = jwtProviderService.provideAccessToken(originId); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); + + String token = jwtProviderService.provideAccessToken(publicClaim); String[] parts = token.split("\\."); String claims = new String(Base64.getDecoder().decode(parts[1])); @@ -79,7 +96,7 @@ void token_authenticate_failBy_payload() { parts[2]); // Then - Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenValid(newToken)) + Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(newToken)) .isInstanceOf(UnauthorizedException.class); } @@ -102,7 +119,25 @@ void token_authenticate_failBy_key() { .compact(); // When + Then - assertThatThrownBy(() -> jwtAuthenticationService.isTokenValid(token)) + assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(token)) .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/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java index e1d84262..c511145a 100644 --- a/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java +++ b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java @@ -10,7 +10,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.moabam.api.dto.PublicClaim; import com.moabam.global.config.TokenConfig; +import com.moabam.support.fixture.PublicClaimFixture; import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; @@ -29,9 +31,10 @@ void create_access_token_success() throws JSONException { TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); // when - String accessToken = jwtProviderService.provideAccessToken(id); + String accessToken = jwtProviderService.provideAccessToken(publicClaim); String[] parts = accessToken.split("\\."); String headers = new String(Base64.getDecoder().decode(parts[0])); @@ -62,7 +65,7 @@ void create_refresh_token_success() throws JSONException { JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); // when - String refreshToken = jwtProviderService.provideRefreshToken(id); + String refreshToken = jwtProviderService.provideRefreshToken(); String[] parts = refreshToken.split("\\."); String headers = new String(Base64.getDecoder().decode(parts[0])); @@ -91,9 +94,10 @@ void create_access_token_fail() { TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); // when - String accessToken = jwtProviderService.provideAccessToken(id); + String accessToken = jwtProviderService.provideAccessToken(publicClaim); // then assertThatThrownBy(() -> Jwts.parserBuilder() @@ -111,9 +115,10 @@ void create_token_fail() { TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); + PublicClaim publicClaim = PublicClaimFixture.publicClaim(); // when - String accessToken = jwtProviderService.provideAccessToken(id); + String accessToken = jwtProviderService.provideAccessToken(publicClaim); // then assertThatThrownBy(() -> Jwts.parserBuilder() diff --git a/src/test/java/com/moabam/api/application/MemberServiceTest.java b/src/test/java/com/moabam/api/application/MemberServiceTest.java index f5ce03a0..1d3fc62b 100644 --- a/src/test/java/com/moabam/api/application/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/MemberServiceTest.java @@ -42,7 +42,7 @@ void member_exist_and_login_success() { LoginResponse result = memberService.login(authorizationTokenInfoResponse); // then - assertThat(result.id()).isEqualTo(member.getId()); + assertThat(result.publicClaim().id()).isEqualTo(member.getId()); assertThat(result.isSignUp()).isFalse(); } @@ -64,7 +64,7 @@ void signUp_success() { LoginResponse result = memberService.login(authorizationTokenInfoResponse); // then - assertThat(authorizationTokenInfoResponse.id()).isEqualTo(result.id()); + assertThat(authorizationTokenInfoResponse.id()).isEqualTo(result.publicClaim().id()); assertThat(result.isSignUp()).isTrue(); } } diff --git a/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java new file mode 100644 index 00000000..9ae4b3f9 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java @@ -0,0 +1,32 @@ +package com.moabam.api.domain.repository; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.entity.Member; +import com.moabam.support.annotation.QuerydslRepositoryTest; +import com.moabam.support.fixture.MemberFixture; + +@QuerydslRepositoryTest +class MemberRepositoryTest { + + @Autowired + MemberRepository memberRepository; + + @DisplayName("") + @Test + void test() { + // given + Member member = MemberFixture.member(); + memberRepository.save(member); + + // when + Member savedMember = memberRepository.findBySocialId(member.getSocialId()).orElse(null); + + // then + Assertions.assertThat(savedMember).isNotNull(); + + } +} diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 4a78621a..4c6700d3 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -20,9 +20,10 @@ import com.moabam.api.application.BugService; import com.moabam.api.dto.BugMapper; import com.moabam.api.dto.BugResponse; +import com.moabam.support.common.WithoutFilterSupporter; -@WebMvcTest(BugController.class) -class BugControllerTest { +@WebMvcTest(controllers = BugController.class) +class BugControllerTest extends WithoutFilterSupporter { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 819b149a..1fc2e129 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -29,6 +29,7 @@ import com.moabam.api.dto.CouponSearchRequest; import com.moabam.api.dto.CreateCouponRequest; import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.CouponFixture; import com.moabam.support.fixture.CouponSnippetFixture; import com.moabam.support.fixture.ErrorSnippetFixture; @@ -37,7 +38,7 @@ @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs -class CouponControllerTest { +class CouponControllerTest extends WithoutFilterSupporter { @Autowired private MockMvc mockMvc; diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 46e25c6d..3eaf9be1 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -27,9 +27,11 @@ import com.moabam.api.dto.ItemMapper; import com.moabam.api.dto.ItemsResponse; import com.moabam.api.dto.PurchaseItemRequest; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; -@WebMvcTest(ItemController.class) -class ItemControllerTest { +@WebMvcTest(controllers = ItemController.class) +class ItemControllerTest extends WithoutFilterSupporter { @Autowired MockMvc mockMvc; @@ -41,6 +43,7 @@ class ItemControllerTest { ItemService itemService; @DisplayName("아이템 목록을 조회한다.") + @WithMember @Test void get_items_success() throws Exception { // given @@ -52,9 +55,8 @@ void get_items_success() throws Exception { given(itemService.getItems(memberId, type)).willReturn(expected); // when, then - String content = mockMvc.perform(get("/items") - .param("type", ItemType.MORNING.name()) - .contentType(APPLICATION_JSON)) + String content = mockMvc.perform( + get("/items").param("type", ItemType.MORNING.name()).contentType(APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()) .andReturn() @@ -73,15 +75,13 @@ void purchase_item_success() throws Exception { PurchaseItemRequest request = new PurchaseItemRequest(BugType.MORNING); // when, then - mockMvc.perform(post("/items/{itemId}/purchase", itemId) - .contentType(APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))) - .andDo(print()) - .andExpect(status().isOk()); + mockMvc.perform(post("/items/{itemId}/purchase", itemId).contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))).andDo(print()).andExpect(status().isOk()); verify(itemService).purchaseItem(memberId, itemId, request); } @DisplayName("아이템을 적용한다.") + @WithMember @Test void select_item_success() throws Exception { // given @@ -89,8 +89,7 @@ void select_item_success() throws Exception { Long itemId = 1L; // when, then - mockMvc.perform(post("/items/{itemId}/select", itemId) - .contentType(APPLICATION_JSON)) + mockMvc.perform(post("/items/{itemId}/select", itemId).contentType(APPLICATION_JSON)) .andDo(print()) .andExpect(status().isOk()); verify(itemService).selectItem(memberId, itemId); diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 1c92c1a5..402017e8 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -32,7 +32,7 @@ import org.springframework.web.util.UriComponentsBuilder; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.application.AuthenticationService; +import com.moabam.api.application.AuthorizationService; import com.moabam.api.application.OAuth2AuthorizationServerRequestService; import com.moabam.api.dto.AuthorizationCodeResponse; import com.moabam.api.dto.AuthorizationTokenInfoResponse; @@ -56,7 +56,7 @@ class MemberControllerTest { OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; @SpyBean - AuthenticationService authenticationService; + AuthorizationService authorizationService; @Autowired OAuthConfig oAuthConfig; @@ -137,7 +137,7 @@ void social_login_signUp_request_success() throws Exception { .andExpectAll( status().isOk(), MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON), - MockMvcResultMatchers.header().string("token_type", "Bearer"), + cookie().value("token_type", "Bearer"), cookie().exists("access_token"), cookie().httpOnly("access_token", true), cookie().secure("access_token", true), @@ -183,7 +183,7 @@ void token_info_response_fail(int code) throws Exception { // when doReturn(AuthorizationResponseFixture.authorizationTokenResponse()) - .when(authenticationService).requestToken(authorizationCodeResponse); + .when(authorizationService).requestToken(authorizationCodeResponse); // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenInfo())) diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index d2c848ac..f9b2a7e5 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -30,6 +30,7 @@ import com.moabam.api.domain.repository.RoomRepository; import com.moabam.global.common.repository.StringRedisRepository; import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.ErrorSnippetFixture; import com.moabam.support.fixture.MemberFixture; import com.moabam.support.fixture.RoomFixture; @@ -38,7 +39,7 @@ @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs -class NotificationControllerTest { +class NotificationControllerTest extends WithoutFilterSupporter { @Autowired private MockMvc mockMvc; diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index 554b9b6b..991c75a5 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -23,9 +23,10 @@ import com.moabam.api.domain.entity.Product; import com.moabam.api.dto.ProductMapper; import com.moabam.api.dto.ProductsResponse; +import com.moabam.support.common.WithoutFilterSupporter; -@WebMvcTest(ProductController.class) -class ProductControllerTest { +@WebMvcTest(controllers = ProductController.class) +class ProductControllerTest extends WithoutFilterSupporter { @Autowired MockMvc mockMvc; diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index e8c2113d..bd4926fe 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -42,6 +42,8 @@ import com.moabam.api.dto.CreateRoomRequest; import com.moabam.api.dto.EnterRoomRequest; import com.moabam.api.dto.ModifyRoomRequest; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.BugFixture; import com.moabam.support.fixture.MemberFixture; @@ -49,7 +51,7 @@ @SpringBootTest @AutoConfigureMockMvc @TestInstance(TestInstance.Lifecycle.PER_CLASS) -class RoomControllerTest { +class RoomControllerTest extends WithoutFilterSupporter { @Autowired private MockMvc mockMvc; @@ -98,6 +100,7 @@ void cleanUp() { } @DisplayName("비밀번호 없는 방 생성 성공") + @WithMember @Test void create_room_no_password_success() throws Exception { // given diff --git a/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java b/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java new file mode 100644 index 00000000..99711dc7 --- /dev/null +++ b/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java @@ -0,0 +1,115 @@ +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.entity.Member; +import com.moabam.api.domain.entity.enums.Role; +import com.moabam.api.dto.AuthorizationMember; +import com.moabam.global.common.annotation.CurrentMember; +import com.moabam.global.common.util.AuthorizationThreadLocal; + +@ExtendWith(MockitoExtension.class) +class CurrentMemberArgumentResolverTest { + + @InjectMocks + CurrentMemberArgumentResolver currentMemberArgumentResolver; + + @Nested + @DisplayName("제공 파라미터 검증") + class SupportParameter { + + @DisplayName("파라미터 제공 성공") + @Test + void support_parameter_success() { + // given + MethodParameter parameter = mock(MethodParameter.class); + + willReturn(mock(CurrentMember.class)) + .given(parameter).getParameterAnnotation(any()); + willReturn(AuthorizationMember.class) + .given(parameter).getParameterType(); + + // when + boolean support = currentMemberArgumentResolver.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 = currentMemberArgumentResolver.supportsParameter(parameter); + + // then + assertThat(support).isFalse(); + } + + @DisplayName("AuthorizationMember 클래스로 받지 않았을 때 실패") + @Test + void support_paramter_failby_not_authorizationmember() { + // given + MethodParameter parameter = mock(MethodParameter.class); + + willReturn(mock(CurrentMember.class)) + .given(parameter).getParameterAnnotation(any()); + willReturn(Member.class) + .given(parameter).getParameterType(); + + // when + boolean support = currentMemberArgumentResolver.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.setAuthorizationMember(new AuthorizationMember(1L, "park", Role.USER)); + + Object object = + currentMemberArgumentResolver.resolveArgument(parameter, mavContainer, webRequest, binderFactory); + + assertAll( + () -> assertThat(object).isNotNull(), + () -> { + AuthorizationMember authorizationMember = (AuthorizationMember)object; + + assertThat(authorizationMember.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..f77cd436 --- /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.entity.enums.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.common.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/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java new file mode 100644 index 00000000..0e762df5 --- /dev/null +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -0,0 +1,174 @@ +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.AuthorizationService; +import com.moabam.api.application.JwtAuthenticationService; +import com.moabam.api.application.JwtProviderService; +import com.moabam.api.dto.AuthorizationMember; +import com.moabam.api.dto.PublicClaim; +import com.moabam.global.common.util.AuthorizationThreadLocal; +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(); + 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)).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(); + 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)).thenReturn(true); + when(jwtAuthenticationService.isTokenExpire(refreshToken)).thenReturn(false); + + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + + // then + verify(authorizationService, times(1)) + .issueServiceToken(httpServletResponse, publicClaim); + + AuthorizationMember authorizationMember = AuthorizationThreadLocal.getAuthorizationMember(); + assertThat(authorizationMember.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..3429bb0a --- /dev/null +++ b/src/test/java/com/moabam/global/filter/PathFilterTest.java @@ -0,0 +1,76 @@ +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.common.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" + }) + 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/WithMember.java b/src/test/java/com/moabam/support/annotation/WithMember.java new file mode 100644 index 00000000..8cfef2f1 --- /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.entity.enums.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/FilterProcessExtension.java b/src/test/java/com/moabam/support/common/FilterProcessExtension.java new file mode 100644 index 00000000..391007dc --- /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.api.dto.AuthorizationMember; +import com.moabam.global.common.util.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.setAuthorizationMember( + new AuthorizationMember(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 AuthorizationMember(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/WithFilterSupporter.java b/src/test/java/com/moabam/support/common/WithFilterSupporter.java new file mode 100644 index 00000000..c5e198ca --- /dev/null +++ b/src/test/java/com/moabam/support/common/WithFilterSupporter.java @@ -0,0 +1,51 @@ +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.JwtProviderService; +import com.moabam.global.common.util.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; + + 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(), + 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..189e6536 --- /dev/null +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -0,0 +1,37 @@ +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 com.moabam.api.application.AuthorizationService; +import com.moabam.api.application.JwtAuthenticationService; +import com.moabam.api.domain.entity.enums.Role; +import com.moabam.global.common.handler.PathResolver; + +@ExtendWith({FilterProcessExtension.class}) +public class WithoutFilterSupporter { + + @MockBean + private JwtAuthenticationService authenticationService; + + @MockBean + private AuthorizationService authorizationService; + + @MockBean + private PathResolver pathResolver; + + @BeforeEach + void setUpMock() { + willReturn(Optional.of(PathResolver.Path.builder() + .uri("/") + .role(Role.USER) + .build())) + .given(pathResolver).permitPathMatch(any()); + } +} 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..951459aa --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java @@ -0,0 +1,19 @@ +package com.moabam.support.fixture; + +import com.moabam.api.application.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 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); + + return new JwtProviderService(tokenConfig); + } +} 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..9e6d2e08 --- /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.entity.enums.Role; +import com.moabam.api.dto.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/resources/application.yml b/src/test/resources/application.yml index 723cc57c..d49e1d93 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -1,6 +1,7 @@ logging: level: org.hibernate.SQL: debug + org.springframework: DEBUG spring: From 43d18ce6f15a3e7b07b8a021252806b9b6b86dd5 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 13 Nov 2023 16:56:55 +0900 Subject: [PATCH 032/185] =?UTF-8?q?=08feat:=20=EB=A3=A8=ED=8B=B4=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#63)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 서버 시간 체크 컨트롤러 구현 * feat: 루틴 인증 기능 및 ClockHolder 구현 * feat: UrlSubstringParser 구현 * test: 루틴 인증 관련 테스트 구현 * refactor: 방 공지 길이 수정 * feat: constant 및 error 작성 * feat: s3 이미지 업로드 기능 구현 * test: s3 이미지 업로드 테스트 * chore: build.gradle s3 추가 * Merge branch 'develop' into feature/#8-upload-image * refactor: build 오류 수정 * test: CertificationsSearchRepository 테스트 * chore: s3Manager 커버리지 제외 * refactor: UrlParser 코드스멜 제거 * refactor: 코드 리뷰 반영 --------- Co-authored-by: ymkim97 Co-authored-by: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> --- build.gradle | 8 +- .../moabam/api/application/ImageService.java | 54 +++++++ .../moabam/api/application/RoomService.java | 116 +++++++++++++++ .../com/moabam/api/domain/entity/Member.java | 4 + .../com/moabam/api/domain/entity/Room.java | 13 +- .../api/domain/entity/enums/RequireExp.java | 43 ++++++ .../CertificationsSearchRepository.java | 23 +++ .../api/domain/resizedimage/ImageName.java | 31 ++++ .../api/domain/resizedimage/ImageResizer.java | 121 +++++++++++++++ .../api/domain/resizedimage/ImageSize.java | 17 +++ .../api/domain/resizedimage/ImageType.java | 8 + .../api/domain/resizedimage/ResizedImage.java | 67 +++++++++ .../moabam/api/dto/CertificationsMapper.java | 34 ++++- .../com/moabam/api/dto/ModifyRoomRequest.java | 2 +- .../api/infrastructure/s3/S3Manager.java | 47 ++++++ .../presentation/HealthCheckController.java | 8 + .../api/presentation/RoomController.java | 12 +- .../global/common/util/ClockHolder.java | 8 + .../global/common/util/GlobalConstant.java | 3 + .../global/common/util/SystemClockHolder.java | 14 ++ .../common/util/UrlSubstringParser.java | 25 ++++ .../global/error/model/ErrorMessage.java | 11 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 4 - .../api/application/ImageServiceTest.java | 62 ++++++++ .../api/application/RoomServiceTest.java | 139 +++++++++++++++++- .../CertificationsSearchRepositoryTest.java | 109 ++++++++++++++ .../common/util/UrlSubstringParserTest.java | 41 ++++++ .../support/config/TestQuerydslConfig.java | 6 + .../moabam/support/fixture/RoomFixture.java | 120 ++++++++++++++- src/test/resources/application.yml | 15 ++ src/test/resources/image.png | Bin 0 -> 52270 bytes 32 files changed, 1153 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ImageService.java create mode 100644 src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageName.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ImageType.java create mode 100644 src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java create mode 100644 src/main/java/com/moabam/api/infrastructure/s3/S3Manager.java create mode 100644 src/main/java/com/moabam/global/common/util/ClockHolder.java create mode 100644 src/main/java/com/moabam/global/common/util/SystemClockHolder.java create mode 100644 src/main/java/com/moabam/global/common/util/UrlSubstringParser.java create mode 100644 src/test/java/com/moabam/api/application/ImageServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java create mode 100644 src/test/java/com/moabam/global/common/util/UrlSubstringParserTest.java create mode 100755 src/test/resources/image.png diff --git a/build.gradle b/build.gradle index 9175fd97..82badb15 100644 --- a/build.gradle +++ b/build.gradle @@ -88,6 +88,10 @@ dependencies { // 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' } tasks.named('test') { @@ -121,6 +125,7 @@ jacocoTestReport { "**/*DynamicQuery*", "**/*BaseTimeEntity*", "**/*HealthCheckController*", + "**/*S3Manager*", ] + Qdomains) }) ) @@ -152,7 +157,8 @@ sonar { 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, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController*' + ',**/*ErrorMessage*.java, **/*Mapper*.java, **/*DynamicQuery*, **/*BaseTimeEntity*, **/*HealthCheckController* ' + + ', **/*S3Manager*.java' property 'sonar.java.checkstyle.reportPaths', 'build/reports/checkstyle/main.xml' } } diff --git a/src/main/java/com/moabam/api/application/ImageService.java b/src/main/java/com/moabam/api/application/ImageService.java new file mode 100644 index 00000000..0fdaac6d --- /dev/null +++ b/src/main/java/com/moabam/api/application/ImageService.java @@ -0,0 +1,54 @@ +package com.moabam.api.application; + +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.resizedimage.ImageName; +import com.moabam.api.domain.resizedimage.ImageResizer; +import com.moabam.api.domain.resizedimage.ImageType; +import com.moabam.api.infrastructure.s3.S3Manager; + +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; + } + + private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType imageType) { + ImageName imageName = ImageName.of(multipartFile, imageType); + + return ImageResizer.builder() + .image(multipartFile) + .fileName(imageName.getFileName()) + .build(); + } + + @Transactional + public void deleteImage(String imageUrl) { + s3Manager.deleteImage(imageUrl); + } +} diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/RoomService.java index 9c35148f..598eeb84 100644 --- a/src/main/java/com/moabam/api/application/RoomService.java +++ b/src/main/java/com/moabam/api/application/RoomService.java @@ -1,17 +1,22 @@ package com.moabam.api.application; import static com.moabam.api.domain.entity.enums.RoomType.*; +import static com.moabam.api.domain.resizedimage.ImageType.*; import static com.moabam.global.error.model.ErrorMessage.*; 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.Optional; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import com.moabam.api.domain.entity.Certification; import com.moabam.api.domain.entity.DailyMemberCertification; @@ -20,8 +25,13 @@ import com.moabam.api.domain.entity.Participant; import com.moabam.api.domain.entity.Room; import com.moabam.api.domain.entity.Routine; +import com.moabam.api.domain.entity.enums.BugType; +import com.moabam.api.domain.entity.enums.RequireExp; import com.moabam.api.domain.entity.enums.RoomType; +import com.moabam.api.domain.repository.CertificationRepository; import com.moabam.api.domain.repository.CertificationsSearchRepository; +import com.moabam.api.domain.repository.DailyMemberCertificationRepository; +import com.moabam.api.domain.repository.DailyRoomCertificationRepository; import com.moabam.api.domain.repository.ParticipantRepository; import com.moabam.api.domain.repository.ParticipantSearchRepository; import com.moabam.api.domain.repository.RoomRepository; @@ -37,6 +47,8 @@ import com.moabam.api.dto.RoutineMapper; import com.moabam.api.dto.RoutineResponse; import com.moabam.api.dto.TodayCertificateRankResponse; +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.ForbiddenException; import com.moabam.global.error.exception.NotFoundException; @@ -54,7 +66,12 @@ public class RoomService { private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; private final CertificationsSearchRepository certificationsSearchRepository; + private final DailyMemberCertificationRepository dailyMemberCertificationRepository; + private final DailyRoomCertificationRepository dailyRoomCertificationRepository; + private final CertificationRepository certificationRepository; private final MemberService memberService; + private final ImageService imageService; + private final ClockHolder clockHolder; @Transactional public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @@ -151,6 +168,63 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { todayCertificateRankResponses, completePercentage); } + @Transactional + public void certifyRoom(Long memberId, Long roomId, List multipartFiles) { + LocalDate today = LocalDate.now(); + Participant participant = getParticipant(memberId, roomId); + Room room = participant.getRoom(); + Member member = memberService.getById(memberId); + BugType bugType = switch (room.getRoomType()) { + case MORNING -> BugType.MORNING; + case NIGHT -> BugType.NIGHT; + }; + int roomLevel = room.getLevel(); + + validateCertifyTime(clockHolder.times(), room.getCertifyTime()); + validateAlreadyCertified(memberId, roomId, today); + + DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, + roomId, participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + member.increaseTotalCertifyCount(); + + List result = imageService.uploadImages(multipartFiles, CERTIFICATION); + saveNewCertifications(result, memberId); + + Optional dailyRoomCertification = + certificationsSearchRepository.findDailyRoomCertification(roomId, today); + + if (dailyRoomCertification.isEmpty()) { + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + if (completePercentage >= 75) { + DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( + roomId, today); + + dailyRoomCertificationRepository.save(createDailyRoomCertification); + + int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); + + List memberIds = dailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + + memberService.getRoomMembers(memberIds) + .forEach(completedMember -> completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); + + return; + } + } + + if (dailyRoomCertification.isPresent()) { + member.getBug().increaseBug(bugType, roomLevel); + } + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); @@ -307,4 +381,46 @@ private double calculateCompletePercentage(int certifiedMembersCount, int curren return Math.round(completePercentage * 100) / 100.0; } + + private void validateCertifyTime(LocalDateTime now, int certifyTime) { + LocalTime targetTime = LocalTime.of(certifyTime, 0); + LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); + LocalDateTime plusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).plusMinutes(10); + + if (now.isBefore(minusTenMinutes) || 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 saveNewCertifications(List imageUrls, Long memberId) { + 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 int getRoomLevelAfterExpApply(int roomLevel, Room room) { + int requireExp = RequireExp.of(roomLevel).getTotalExp(); + room.gainExp(); + + if (room.getExp() == requireExp) { + room.levelUp(); + } + + return room.getLevel(); + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/entity/Member.java index 13029124..bee72c09 100644 --- a/src/main/java/com/moabam/api/domain/entity/Member.java +++ b/src/main/java/com/moabam/api/domain/entity/Member.java @@ -112,4 +112,8 @@ public void exitNightRoom() { public int getLevel() { return (int)(totalCertifyCount / LEVEL_DIVISOR) + 1; } + + public void increaseTotalCertifyCount() { + this.totalCertifyCount++; + } } diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/entity/Room.java index b80d708f..10a9c13c 100644 --- a/src/main/java/com/moabam/api/domain/entity/Room.java +++ b/src/main/java/com/moabam/api/domain/entity/Room.java @@ -49,9 +49,14 @@ public class Room extends BaseTimeEntity { @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; @@ -65,7 +70,7 @@ public class Room extends BaseTimeEntity { @Column(name = "max_user_count", nullable = false) private int maxUserCount; - @Column(name = "announcement", length = 255) + @Column(name = "announcement", length = 100) private String announcement; @ColumnDefault(ROOM_LEVEL_0_IMAGE) @@ -78,6 +83,7 @@ private Room(Long id, String title, String password, RoomType roomType, int cert 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; @@ -87,6 +93,11 @@ private Room(Long id, String title, String password, RoomType roomType, int cert public void levelUp() { this.level += 1; + this.exp = 0; + } + + public void gainExp() { + this.exp += 1; } public void changeAnnouncement(String announcement) { diff --git a/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java b/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java new file mode 100644 index 00000000..96233fd9 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java @@ -0,0 +1,43 @@ +package com.moabam.api.domain.entity.enums; + +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 RequireExp { + + 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(RequireExp::getLevel, RequireExp::name)) + ); + + private final int level; + private final int totalExp; + + public static RequireExp of(int level) { + return RequireExp.valueOf(requireExpMap.get(level)); + } +} diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java index ee921314..f8c5488c 100644 --- a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java @@ -8,6 +8,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.util.List; +import java.util.Optional; import org.springframework.stereotype.Repository; @@ -33,6 +34,18 @@ public List findCertifications(Long roomId, LocalDate date) { .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) @@ -47,6 +60,16 @@ public List findSortedDailyMemberCertifications(Long r .fetch(); } + public Optional findDailyRoomCertification(Long roomId, LocalDate date) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(dailyRoomCertification) + .where( + dailyRoomCertification.roomId.eq(roomId), + dailyRoomCertification.certifiedAt.eq(date) + ) + .fetchOne()); + } + public List findDailyRoomCertifications(Long roomId, LocalDate date) { return jpaQueryFactory .selectFrom(dailyRoomCertification) diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java new file mode 100644 index 00000000..45ee6d4a --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java @@ -0,0 +1,31 @@ +package com.moabam.api.domain.resizedimage; + +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 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()); + case PROFILE_IMAGE -> new ImageName(PROFILE_IMAGE + file.getName() + "_" + UUID.randomUUID()); + case DEFAULT -> new ImageName(DEFAULT + file.getName()); + }; + } +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java new file mode 100644 index 00000000..409aa4e5 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java @@ -0,0 +1,121 @@ +package com.moabam.api.domain.resizedimage; + +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 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 ResizedImage toMultipartFile(byte[] bytes) { + return ResizedImage.of(fileName, image.getContentType(), bytes); + } +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java new file mode 100644 index 00000000..c6c5ed06 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java @@ -0,0 +1,17 @@ +package com.moabam.api.domain.resizedimage; + +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/resizedimage/ImageType.java b/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java new file mode 100644 index 00000000..cf9936e5 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java @@ -0,0 +1,8 @@ +package com.moabam.api.domain.resizedimage; + +public enum ImageType { + + PROFILE_IMAGE, + CERTIFICATION, + DEFAULT +} diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java b/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java new file mode 100644 index 00000000..6920382c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java @@ -0,0 +1,67 @@ +package com.moabam.api.domain.resizedimage; + +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 ResizedImage implements MultipartFile { + + private final String name; + private final String contentType; + private final long size; + private final byte[] bytes; + + public static ResizedImage of(String name, String contentType, byte[] bytes) { + return new ResizedImage(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/dto/CertificationsMapper.java b/src/main/java/com/moabam/api/dto/CertificationsMapper.java index 866ecf40..bbaf37f4 100644 --- a/src/main/java/com/moabam/api/dto/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/dto/CertificationsMapper.java @@ -1,12 +1,15 @@ package com.moabam.api.dto; +import java.time.LocalDate; import java.util.ArrayList; import java.util.List; import com.moabam.api.domain.entity.Certification; +import com.moabam.api.domain.entity.DailyMemberCertification; +import com.moabam.api.domain.entity.DailyRoomCertification; import com.moabam.api.domain.entity.Member; -import com.moabam.api.dto.CertificationImageResponse; -import com.moabam.api.dto.TodayCertificateRankResponse; +import com.moabam.api.domain.entity.Participant; +import com.moabam.api.domain.entity.Routine; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -16,6 +19,7 @@ public final class CertificationsMapper { public static List toCertificateImageResponses(Long memberId, List certifications) { + List cftImageResponses = new ArrayList<>(); List filteredCertifications = certifications.stream() .filter(certification -> certification.getMemberId().equals(memberId)) @@ -36,6 +40,7 @@ public static List toCertificateImageResponses(Long public static TodayCertificateRankResponse toTodayCertificateRankResponse(int rank, Member member, int contributionPoint, String awakeImage, String sleepImage, List certificationImageResponses) { + return TodayCertificateRankResponse.builder() .rank(rank) .memberId(member.getId()) @@ -47,4 +52,29 @@ public static TodayCertificateRankResponse toTodayCertificateRankResponse(int ra .certificationImage(certificationImageResponses) .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(); + } } diff --git a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java index 017fc877..ecef68bc 100644 --- a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java @@ -12,7 +12,7 @@ public record ModifyRoomRequest( @NotBlank @Length(max = 20) String title, - @Length(max = 255, message = "방 공지의 길이가 너무 깁니다.") String announcement, + @Length(max = 100, message = "방 공지의 길이 100자 이하여야 합니다.") String announcement, @NotNull @Size(min = 1, max = 4) List routines, @Pattern(regexp = "^(|\\d{4,8})$") String password, @Range(min = 0, max = 23) int certifyTime, 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/presentation/HealthCheckController.java b/src/main/java/com/moabam/api/presentation/HealthCheckController.java index 4f67e4c2..5d72ea28 100644 --- a/src/main/java/com/moabam/api/presentation/HealthCheckController.java +++ b/src/main/java/com/moabam/api/presentation/HealthCheckController.java @@ -1,5 +1,7 @@ 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; @@ -13,4 +15,10 @@ public class HealthCheckController { 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/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index f7489136..a03d6d19 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,5 +1,7 @@ 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; @@ -8,8 +10,10 @@ 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.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.RoomService; import com.moabam.api.dto.CreateRoomRequest; @@ -42,7 +46,7 @@ public void modifyRoom(@Valid @RequestBody ModifyRoomRequest modifyRoomRequest, @PostMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public void enterRoom(@Valid @RequestBody EnterRoomRequest enterRoomRequest, @PathVariable("roomId") Long roomId) { + public void enterRoom(@PathVariable("roomId") Long roomId, @Valid @RequestBody EnterRoomRequest enterRoomRequest) { roomService.enterRoom(1L, roomId, enterRoomRequest); } @@ -57,4 +61,10 @@ public void exitRoom(@PathVariable("roomId") Long roomId) { public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { return roomService.getRoomDetails(1L, roomId); } + + @PostMapping("/{roomId}/certification") + @ResponseStatus(HttpStatus.CREATED) + public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { + roomService.certifyRoom(1L, roomId, multipartFiles); + } } 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..414ce25c --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/ClockHolder.java @@ -0,0 +1,8 @@ +package com.moabam.global.common.util; + +import java.time.LocalDateTime; + +public interface ClockHolder { + + LocalDateTime times(); +} diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 5d111d65..8f43ec67 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -7,6 +7,9 @@ public class GlobalConstant { public static final String BLANK = ""; + public static final String COMMA = ","; + public static final String UNDER_BAR = "_"; + public static final String DELIMITER = "/"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; public static final int ONE_HOUR = 1; 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..8662d0da --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java @@ -0,0 +1,14 @@ +package com.moabam.global.common.util; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + +@Component +public class SystemClockHolder implements ClockHolder { + + @Override + public LocalDateTime times() { + return LocalDateTime.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/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 6a9904f6..a1bc6d53 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -22,6 +22,10 @@ public enum ErrorMessage { ROOM_MAX_USER_REACHED("방의 인원수가 찼습니다."), ROOM_DETAILS_ERROR("방 정보를 불러오는데 실패했습니다."), ROUTINE_LENGTH_ERROR("루틴의 길이가 잘못 되었습니다."), + DUPLICATED_DAILY_MEMBER_CERTIFICATION("이미 오늘의 인증을 완료하였습니다."), + ROUTINE_NOT_FOUND("루틴을 찾을 수 없습니다"), + INVALID_REQUEST_URL("잘못된 URL 요청입니다."), + INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), @@ -52,7 +56,12 @@ public enum ErrorMessage { INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), - NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."); + NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), + + S3_UPLOAD_FAIL("S3 업로드를 실패했습니다."), + S3_INVALID_IMAGE("올바른 이미지(파일) 형식이 아닙니다."), + S3_INVALID_IMAGE_SIZE("파일의 용량이 너무 큽니다."), + S3_RESIZE_ERROR("이미지 리사이징에서 에러가 발생했습니다."); private final String message; } diff --git a/src/main/resources/config b/src/main/resources/config index f392e58a..73b984ec 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit f392e58aefb231e765995b30c8c0194a67756b8c +Subproject commit 73b984ec52bfcc872a0acbe4b5a038dcc1d79262 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index b3b98263..89b81eb9 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -2132,11 +2132,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-<<<<<<< HEAD
-Content-Length: 194
-=======
 Content-Length: 192
->>>>>>> b5f9a450e8eb3b4f4527d412d519e6afc9c16365
 Host: localhost:8080
 
 {
diff --git a/src/test/java/com/moabam/api/application/ImageServiceTest.java b/src/test/java/com/moabam/api/application/ImageServiceTest.java
new file mode 100644
index 00000000..2a76c5f7
--- /dev/null
+++ b/src/test/java/com/moabam/api/application/ImageServiceTest.java
@@ -0,0 +1,62 @@
+package com.moabam.api.application;
+
+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.resizedimage.ImageType;
+import com.moabam.api.domain.resizedimage.ResizedImage;
+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(ResizedImage.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/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java
index dd626f18..f8b438ad 100644
--- a/src/test/java/com/moabam/api/application/RoomServiceTest.java
+++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java
@@ -4,26 +4,44 @@
 import static org.assertj.core.api.Assertions.*;
 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.ArgumentMatchers;
 import org.mockito.InjectMocks;
 import org.mockito.Mock;
+import org.mockito.Spy;
 import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.mock.web.MockMultipartFile;
+import org.springframework.web.multipart.MultipartFile;
 
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Member;
 import com.moabam.api.domain.entity.Participant;
 import com.moabam.api.domain.entity.Room;
 import com.moabam.api.domain.entity.Routine;
 import com.moabam.api.domain.repository.CertificationRepository;
+import com.moabam.api.domain.repository.CertificationsSearchRepository;
+import com.moabam.api.domain.repository.DailyMemberCertificationRepository;
+import com.moabam.api.domain.repository.DailyRoomCertificationRepository;
 import com.moabam.api.domain.repository.ParticipantRepository;
+import com.moabam.api.domain.repository.ParticipantSearchRepository;
 import com.moabam.api.domain.repository.RoomRepository;
 import com.moabam.api.domain.repository.RoutineRepository;
+import com.moabam.api.domain.resizedimage.ImageType;
 import com.moabam.api.dto.CreateRoomRequest;
 import com.moabam.api.dto.RoomMapper;
+import com.moabam.global.common.util.ClockHolder;
+import com.moabam.support.fixture.MemberFixture;
+import com.moabam.support.fixture.RoomFixture;
 
 @ExtendWith(MockitoExtension.class)
 class RoomServiceTest {
@@ -31,17 +49,69 @@ class RoomServiceTest {
 	@InjectMocks
 	private RoomService roomService;
 
+	@Mock
+	private MemberService memberService;
+
 	@Mock
 	private RoomRepository roomRepository;
 
 	@Mock
 	private RoutineRepository routineRepository;
 
+	@Mock
+	private ParticipantRepository participantRepository;
+
 	@Mock
 	private CertificationRepository certificationRepository;
 
 	@Mock
-	private ParticipantRepository participantRepository;
+	private CertificationsSearchRepository certificationsSearchRepository;
+
+	@Mock
+	private ParticipantSearchRepository participantSearchRepository;
+
+	@Mock
+	private DailyRoomCertificationRepository dailyRoomCertificationRepository;
+
+	@Mock
+	private DailyMemberCertificationRepository dailyMemberCertificationRepository;
+
+	@Mock
+	private ImageService imageService;
+
+	@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(1L, "회원1");
+		member2 = MemberFixture.member(2L, "회원2");
+		member3 = MemberFixture.member(3L, "회원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
@@ -92,4 +162,71 @@ void create_room_with_password_success() {
 		assertThat(result).isEqualTo(expectedRoom.getId());
 		assertThat(expectedRoom.getPassword()).isEqualTo("1234");
 	}
+
+	@DisplayName("이미 인증되어 있는 방에서 루틴 인증 성공")
+	@Test
+	void already_certified_room_routine_success() {
+		// given
+		List routines = RoomFixture.routines(room);
+		DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today);
+		MockMultipartFile image = RoomFixture.makeMultipartFile1();
+		List images = List.of(image, image, image);
+		List uploadImages = new ArrayList<>();
+		uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd");
+		uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd");
+
+		given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages);
+		given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58));
+		given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant));
+		given(memberService.getById(memberId)).willReturn(member1);
+		given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0)));
+		given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1)));
+		given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)).willReturn(
+			Optional.of(dailyRoomCertification));
+
+		// when
+		roomService.certifyRoom(memberId, roomId, images);
+
+		// then
+		assertThat(member1.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member1.getTotalCertifyCount()).isEqualTo(1);
+	}
+
+	@DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공")
+	@Test
+	void not_certified_room_routine_success() {
+		// given
+		List routines = RoomFixture.routines(room);
+		MockMultipartFile image = RoomFixture.makeMultipartFile1();
+		List dailyMemberCertifications =
+			RoomFixture.dailyMemberCertifications(roomId, participant);
+		List images = List.of(image, image, image);
+		List uploadImages = new ArrayList<>();
+		uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd");
+		uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd");
+
+		given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages);
+		given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58));
+		given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant));
+		given(memberService.getById(memberId)).willReturn(member1);
+		given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0)));
+		given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1)));
+		given(certificationsSearchRepository.findDailyRoomCertification(roomId, today))
+			.willReturn(Optional.empty());
+		given(certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today))
+			.willReturn(dailyMemberCertifications);
+		given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3));
+
+		// when
+		roomService.certifyRoom(memberId, roomId, images);
+
+		// then
+		assertThat(member1.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member2.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member3.getBug().getMorningBug()).isEqualTo(12);
+		assertThat(member3.getBug().getNightBug()).isEqualTo(20);
+		assertThat(member3.getBug().getGoldenBug()).isEqualTo(30);
+		assertThat(room.getExp()).isEqualTo(1);
+		assertThat(room.getLevel()).isEqualTo(2);
+	}
 }
diff --git a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java
new file mode 100644
index 00000000..bedca849
--- /dev/null
+++ b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java
@@ -0,0 +1,109 @@
+package com.moabam.api.domain.repository;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+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.entity.Certification;
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Participant;
+import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.entity.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/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/support/config/TestQuerydslConfig.java b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
index fb7d6d8f..a30f0e02 100644
--- a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
+++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java
@@ -4,6 +4,7 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
 
+import com.moabam.api.domain.repository.CertificationsSearchRepository;
 import com.moabam.api.domain.repository.InventorySearchRepository;
 import com.moabam.api.domain.repository.ItemSearchRepository;
 import com.querydsl.jpa.impl.JPAQueryFactory;
@@ -32,4 +33,9 @@ public ItemSearchRepository itemSearchRepository() {
 	public InventorySearchRepository inventorySearchRepository() {
 		return new InventorySearchRepository(jpaQueryFactory());
 	}
+
+	@Bean
+	public CertificationsSearchRepository certificationsSearchRepository() {
+		return new CertificationsSearchRepository(jpaQueryFactory());
+	}
 }
diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java
index b65518b4..95732c76 100644
--- a/src/test/java/com/moabam/support/fixture/RoomFixture.java
+++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java
@@ -1,9 +1,23 @@
 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.entity.Certification;
+import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.entity.Participant;
 import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.entity.Routine;
 import com.moabam.api.domain.entity.enums.RoomType;
 
-public final class RoomFixture {
+public class RoomFixture {
 
 	public static Room room() {
 		return Room.builder()
@@ -22,4 +36,108 @@ public static Room room(int certifyTime) {
 			.maxUserCount(8)
 			.build();
 	}
+
+	public static Participant participant(Room room, Long memberId) {
+		return Participant.builder()
+			.room(room)
+			.memberId(memberId)
+			.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/resources/application.yml b/src/test/resources/application.yml
index d49e1d93..c140a299 100644
--- a/src/test/resources/application.yml
+++ b/src/test/resources/application.yml
@@ -20,6 +20,21 @@ spring:
       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
diff --git a/src/test/resources/image.png b/src/test/resources/image.png
new file mode 100755
index 0000000000000000000000000000000000000000..0950368c3e8259058e328e9c713cc71766f4da93
GIT binary patch
literal 52270
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

literal 0
HcmV?d00001


From baf4703c010e0494032d6e62e4c3f44906cfccd6 Mon Sep 17 00:00:00 2001
From: Park Seyeon 
Date: Mon, 13 Nov 2023 17:14:12 +0900
Subject: [PATCH 033/185] =?UTF-8?q?feat:=20healthCheck=20path=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80=20(#66)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: 회원 엔티티 생성 및 테스트코드 추가

* feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩

* feat: authorization code를 받기 위한 queryString generator 추가

* feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가

* feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환

* refactor: member관련 클래스 네이밍과 폴더 위치 변경

* refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경

* style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제

* chore: config 파일 업데이트

* refactor: 테스트 코드 추가 및 코드 포맷 재적용

* refactor: 사용하지 않는 코드 제거

* refactor: CRLF -> LF로 변경

* fix: config 커밋, config 최근 커밋으로 변경

* feat: 테스트 코드 추가 및 패키지 구조 변경

* refactor: revert merge

* fix: merge confilt해결 및 예외처리 추가

* test: oauth properties가 없을 때의 테스트코드 추가

* feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경

* fix: 테스트코드 관련 code smell 제거

* feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가

* feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가

* refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리

* test: 로그인 요청 테스트 코드 추가

* feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경

* test: restTemplate 및 서비스 테스트 추가

* refactor: 에러 메세지 이름 변경

* refacotr: 변수명 및 entity default 명 변경

* feat: 토큰 정보 조회 기능 및 테스트 추가

* feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경

* fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성

* feat: JWT 토큰 제공 서비스 및 테스트 코드 추가

* feat: 토큰 인증 코드 및 테스트 코드 작성

* feat: 로그인 및 회원가입 기능 추가

- 회원의 socialId string -> long으로 변경

* feat: 회원 로그인 테스트 코드 추가

* chore: 코드 포메팅 재 설정

* feat: config 파일 업데이트

* feat: Window용 포트 redis 포트 변경 추가

* refacotr: develop 업데이트 사항 merge

* refactor: develop 업데이트 부분 merge

* fix: TimeConfig 삭제 및 코드 스멜 변경

* refactor: 코르리뷰 반영

* chore: submodule update

* feat: 메서드 파싱 customizing 및 @CurrentMember AuthorizationMember 를 파라미터로 감지하는 조건 추가

* feat: 인가회원에 대한 객체 ThreadLocalMap에 저장하는 기능 추가

* fix: 회원 정보 Optional 정보 조회 버그 fix, socialId requiredNotNull추가 등 에러 수정

* feat: API요청 Path 및 인증에 따른 filter 추가

- PathFilter: PathResolver, WebConfig
-  AuthorizationFilter:AuthorizationService, JwtAuthenticationService, JwtProviderService, MemberService
- Member info: CurrentMember, AuthorizationMember, LoginResponse, MemberMapper, CurrentMember, PublicClaim, CurrentMemberArgumentResolver

* test: CurrentMember 테스트 support 추가

* test: authorizationfilter 및 pathfilter 테스트 추가

* test: 회원 repostiory 및 fixture 추가

* test: filter support 클랠스 추가

* test: filter support 클래스 적용

* refactor: PublicClaim 변환 책임 변경

* test: PathResolver, CurrentMemberArgumentResovler테스트 코드 추가

* fix: 모든 쿠키 secure 적용되도록 변경

* refactor: 클래스 명 변경

* refactor: webConfig Path 매핑 클래스 추가

* feat: healthcheck path 추가
---
 src/main/java/com/moabam/global/config/WebConfig.java | 1 +
 src/main/resources/config                             | 2 +-
 src/main/resources/static/docs/coupon.html            | 4 ++++
 3 files changed, 6 insertions(+), 1 deletion(-)

diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java
index 166265d5..7de73c53 100644
--- a/src/main/java/com/moabam/global/config/WebConfig.java
+++ b/src/main/java/com/moabam/global/config/WebConfig.java
@@ -43,6 +43,7 @@ public HandlerMethodArgumentResolver handlerMethodArgumentResolver() {
 	public PathResolver pathResolver() {
 		PathResolver.Paths path = PathResolver.Paths.builder()
 			.permitAll(List.of(
+				PathMapper.parsePath("/"),
 				PathMapper.parsePath("/members"),
 				PathMapper.parsePath("/members/login/*/oauth")
 			))
diff --git a/src/main/resources/config b/src/main/resources/config
index 73b984ec..f392e58a 160000
--- a/src/main/resources/config
+++ b/src/main/resources/config
@@ -1 +1 @@
-Subproject commit 73b984ec52bfcc872a0acbe4b5a038dcc1d79262
+Subproject commit f392e58aefb231e765995b30c8c0194a67756b8c
diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html
index 89b81eb9..b3b98263 100644
--- a/src/main/resources/static/docs/coupon.html
+++ b/src/main/resources/static/docs/coupon.html
@@ -2132,7 +2132,11 @@ 

요청

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 206bb94a..0673186a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java b/src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java new file mode 100644 index 00000000..67a5daa3 --- /dev/null +++ b/src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java @@ -0,0 +1,183 @@ +package com.moabam.api.application; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyList; +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 org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.RoomCertificationService; +import com.moabam.api.domain.image.ImageType; +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.global.common.util.ClockHolder; +import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class RoomCertificationServiceTest { + + @InjectMocks + private RoomCertificationService roomCertificationService; + + @Mock + private MemberService memberService; + + @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 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(1L, "회원1"); + member2 = MemberFixture.member(2L, "회원2"); + member3 = MemberFixture.member(3L, "회원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 already_certified_room_routine_success() { + // given + List routines = RoomFixture.routines(room); + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today); + MockMultipartFile image = RoomFixture.makeMultipartFile1(); + List images = List.of(image, image, image); + List uploadImages = new ArrayList<>(); + uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); + uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); + + given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); + given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); + given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); + given(memberService.getById(memberId)).willReturn(member1); + given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); + given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); + given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)).willReturn( + Optional.of(dailyRoomCertification)); + + // when + roomCertificationService.certifyRoom(memberId, roomId, images); + + // then + assertThat(member1.getBug().getMorningBug()).isEqualTo(12); + assertThat(member1.getTotalCertifyCount()).isEqualTo(1); + } + + @DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공") + @Test + void not_certified_room_routine_success() { + // given + List routines = RoomFixture.routines(room); + MockMultipartFile image = RoomFixture.makeMultipartFile1(); + List dailyMemberCertifications = + RoomFixture.dailyMemberCertifications(roomId, participant); + List images = List.of(image, image, image); + List uploadImages = new ArrayList<>(); + uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); + uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); + + given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); + given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); + given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); + given(memberService.getById(memberId)).willReturn(member1); + given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); + given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); + given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)) + .willReturn(Optional.empty()); + given(certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today)) + .willReturn(dailyMemberCertifications); + given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3)); + + // when + roomCertificationService.certifyRoom(memberId, roomId, images); + + // then + assertThat(member1.getBug().getMorningBug()).isEqualTo(12); + assertThat(member2.getBug().getMorningBug()).isEqualTo(12); + assertThat(member3.getBug().getMorningBug()).isEqualTo(12); + assertThat(member3.getBug().getNightBug()).isEqualTo(20); + assertThat(member3.getBug().getGoldenBug()).isEqualTo(30); + assertThat(room.getExp()).isEqualTo(1); + assertThat(room.getLevel()).isEqualTo(2); + } + +} diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java index 0ed0b004..77100f14 100644 --- a/src/test/java/com/moabam/api/application/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java @@ -4,47 +4,28 @@ import static org.assertj.core.api.Assertions.*; 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.ArgumentMatchers; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.application.image.ImageService; import com.moabam.api.application.member.MemberService; import com.moabam.api.application.room.RoomService; import com.moabam.api.application.room.mapper.RoomMapper; -import com.moabam.api.domain.image.ImageType; -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.CreateRoomRequest; -import com.moabam.global.common.util.ClockHolder; -import com.moabam.support.fixture.MemberFixture; -import com.moabam.support.fixture.RoomFixture; @ExtendWith(MockitoExtension.class) class RoomServiceTest { @@ -64,58 +45,9 @@ class RoomServiceTest { @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 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(1L, "회원1"); - member2 = MemberFixture.member(2L, "회원2"); - member3 = MemberFixture.member(3L, "회원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 create_room_no_password_success() { @@ -165,71 +97,4 @@ void create_room_with_password_success() { assertThat(result).isEqualTo(expectedRoom.getId()); assertThat(expectedRoom.getPassword()).isEqualTo("1234"); } - - @DisplayName("이미 인증되어 있는 방에서 루틴 인증 성공") - @Test - void already_certified_room_routine_success() { - // given - List routines = RoomFixture.routines(room); - DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today); - MockMultipartFile image = RoomFixture.makeMultipartFile1(); - List images = List.of(image, image, image); - List uploadImages = new ArrayList<>(); - uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); - uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); - - given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); - given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); - given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); - given(memberService.getById(memberId)).willReturn(member1); - given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); - given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); - given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)).willReturn( - Optional.of(dailyRoomCertification)); - - // when - roomService.certifyRoom(memberId, roomId, images); - - // then - assertThat(member1.getBug().getMorningBug()).isEqualTo(12); - assertThat(member1.getTotalCertifyCount()).isEqualTo(1); - } - - @DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공") - @Test - void not_certified_room_routine_success() { - // given - List routines = RoomFixture.routines(room); - MockMultipartFile image = RoomFixture.makeMultipartFile1(); - List dailyMemberCertifications = - RoomFixture.dailyMemberCertifications(roomId, participant); - List images = List.of(image, image, image); - List uploadImages = new ArrayList<>(); - uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); - uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); - - given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); - given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); - given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); - given(memberService.getById(memberId)).willReturn(member1); - given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); - given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); - given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)) - .willReturn(Optional.empty()); - given(certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today)) - .willReturn(dailyMemberCertifications); - given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3)); - - // when - roomService.certifyRoom(memberId, roomId, images); - - // then - assertThat(member1.getBug().getMorningBug()).isEqualTo(12); - assertThat(member2.getBug().getMorningBug()).isEqualTo(12); - assertThat(member3.getBug().getMorningBug()).isEqualTo(12); - assertThat(member3.getBug().getNightBug()).isEqualTo(20); - assertThat(member3.getBug().getGoldenBug()).isEqualTo(30); - assertThat(room.getExp()).isEqualTo(1); - assertThat(room.getLevel()).isEqualTo(2); - } } From 622bc976b6f7f1ba8ee4bfd5479155f7d522e2e4 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:25:45 +0900 Subject: [PATCH 038/185] =?UTF-8?q?=08feat:=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EC=9C=84=EC=9E=84=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방장 위임 기능 구현 * test: 방장 위임 기능 테스트 작성 * test: 방장이 아닌 유저의 요청인 경우 추가 --- .../api/application/room/RoomService.java | 21 ++++++-- .../api/presentation/RoomController.java | 6 +++ .../api/application/RoomServiceTest.java | 53 +++++++++++++++++++ 3 files changed, 76 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 4382fce5..8b98b936 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -63,10 +63,7 @@ public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @Transactional public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomRequest) { Participant participant = getParticipant(memberId, roomId); - - if (!participant.isManager()) { - throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); - } + validateManagerAuthorization(participant); Room room = participant.getRoom(); room.changeTitle(modifyRoomRequest.title()); @@ -119,6 +116,16 @@ public void exitRoom(Long memberId, Long roomId) { roomRepository.delete(room); } + @Transactional + public void mandateRoomManager(Long managerId, Long roomId, Long memberId) { + Participant managerParticipant = getParticipant(managerId, roomId); + Participant memberParticipant = getParticipant(memberId, roomId); + validateManagerAuthorization(managerParticipant); + + managerParticipant.disableManager(); + memberParticipant.enableManager(); + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); @@ -130,6 +137,12 @@ private Participant getParticipant(Long memberId, Long roomId) { .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); } + private void validateManagerAuthorization(Participant participant) { + if (!participant.isManager()) { + throw new ForbiddenException(ROOM_MODIFY_UNAUTHORIZED_REQUEST); + } + } + private void validateRoomEnter(Long memberId, String requestPassword, Room room) { if (!isEnterRoomAvailable(memberId, room.getRoomType())) { throw new BadRequestException(MEMBER_ROOM_EXCEED); diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index dd3e1a89..978d7c3f 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -71,4 +71,10 @@ public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { roomCertificationService.certifyRoom(1L, roomId, multipartFiles); } + + @PutMapping("/{roomId}/members/{memberId}/mandate") + @ResponseStatus(HttpStatus.OK) + public void mandateManager(@PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { + roomService.mandateRoomManager(1L, roomId, memberId); + } } diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java index 77100f14..c0be712a 100644 --- a/src/test/java/com/moabam/api/application/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,6 +27,8 @@ 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.RoomFixture; @ExtendWith(MockitoExtension.class) class RoomServiceTest { @@ -97,4 +100,54 @@ void create_room_with_password_success() { assertThat(result).isEqualTo(expectedRoom.getId()); assertThat(expectedRoom.getPassword()).isEqualTo("1234"); } + + @DisplayName("방장 위임 성공") + @Test + void room_manager_mandate_success() { + // 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); + managerParticipant.enableManager(); + + given(participantSearchRepository.findOne(memberId, room.getId())).willReturn( + Optional.of(memberParticipant)); + given(participantSearchRepository.findOne(managerId, room.getId())).willReturn( + Optional.of(managerParticipant)); + + // when + roomService.mandateRoomManager(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.mandateRoomManager(managerId, 1L, memberId)) + .isInstanceOf(ForbiddenException.class); + } } From a3ac321e057a4443055f242c422a82d2d89f2987 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Tue, 14 Nov 2023 16:30:46 +0900 Subject: [PATCH 039/185] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EB=B2=8C=EB=A0=88=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 오늘 얻은 벌레 조회 API 구현 * refactor: 쿼리 1번으로 수정 * feat: @CurrentMember 적용 * test: 벌레 조회 Controller 통합 테스트 * chore: 주석 제거 * test: 오늘 보상 벌레 조회 Controller 테스트 * test: memberService mock 처리 * chore: enum 비교 equals로 변경 --- .../moabam/api/application/bug/BugMapper.java | 8 +++ .../api/application/bug/BugService.java | 27 +++++++++ .../BugHistorySearchRepository.java | 40 +++++++++++++ .../com/moabam/api/dto/TodayBugResponse.java | 11 ++++ .../api/presentation/BugController.java | 13 +++- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../api/presentation/BugControllerTest.java | 60 ++++++++++++++++--- .../support/fixture/BugHistoryFixture.java | 26 ++++++++ 10 files changed, 178 insertions(+), 13 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/TodayBugResponse.java create mode 100644 src/test/java/com/moabam/support/fixture/BugHistoryFixture.java diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index 0d439458..89a4fe83 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -4,6 +4,7 @@ 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.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; import lombok.AccessLevel; @@ -20,6 +21,13 @@ public static BugResponse toBugResponse(Bug bug) { .build(); } + public static TodayBugResponse toTodayBugResponse(int morningBug, int nightBug) { + return TodayBugResponse.builder() + .morningBug(morningBug) + .nightBug(nightBug) + .build(); + } + public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int quantity) { return BugHistory.builder() .memberId(memberId) diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 880eb8f9..eea27cf9 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -1,11 +1,21 @@ package com.moabam.api.application.bug; +import static com.moabam.api.domain.bug.BugActionType.*; +import static com.moabam.api.domain.bug.BugType.*; + +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.domain.bug.BugHistory; +import com.moabam.api.domain.bug.BugType; import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.repository.BugHistorySearchRepository; +import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.global.common.util.ClockHolder; import lombok.RequiredArgsConstructor; @@ -15,10 +25,27 @@ public class BugService { private final MemberService memberService; + private final BugHistorySearchRepository bugHistorySearchRepository; + private final ClockHolder clockHolder; public BugResponse getBug(Long memberId) { Member member = memberService.getById(memberId); return BugMapper.toBugResponse(member.getBug()); } + + public TodayBugResponse getTodayBug(Long memberId) { + List todayRewardBug = bugHistorySearchRepository.find(memberId, REWARD, clockHolder.times()); + int morningBug = calculateBugQuantity(todayRewardBug, MORNING); + int nightBug = calculateBugQuantity(todayRewardBug, NIGHT); + + return BugMapper.toTodayBugResponse(morningBug, nightBug); + } + + private int calculateBugQuantity(List bugHistory, BugType bugType) { + return bugHistory.stream() + .filter(history -> bugType.equals(history.getBugType())) + .mapToInt(BugHistory::getQuantity) + .sum(); + } } diff --git a/src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java b/src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java new file mode 100644 index 00000000..724289af --- /dev/null +++ b/src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java @@ -0,0 +1,40 @@ +package com.moabam.api.domain.repository; + +import static com.moabam.api.domain.bug.QBugHistory.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.bug.BugActionType; +import com.moabam.api.domain.bug.BugHistory; +import com.moabam.global.common.util.DynamicQuery; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class BugHistorySearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public List find(Long memberId, BugActionType actionType, LocalDateTime dateTime) { + return jpaQueryFactory + .selectFrom(bugHistory) + .where( + DynamicQuery.generateEq(memberId, bugHistory.memberId::eq), + DynamicQuery.generateEq(actionType, bugHistory.actionType::eq), + DynamicQuery.generateEq(dateTime, this::equalDate) + ) + .fetch(); + } + + private BooleanExpression equalDate(LocalDateTime dateTime) { + return bugHistory.createdAt.year().eq(dateTime.getYear()) + .and(bugHistory.createdAt.month().eq(dateTime.getMonthValue())) + .and(bugHistory.createdAt.dayOfMonth().eq(dateTime.getDayOfMonth())); + } +} diff --git a/src/main/java/com/moabam/api/dto/TodayBugResponse.java b/src/main/java/com/moabam/api/dto/TodayBugResponse.java new file mode 100644 index 00000000..fb5282d5 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/TodayBugResponse.java @@ -0,0 +1,11 @@ +package com.moabam.api.dto; + +import lombok.Builder; + +@Builder +public record TodayBugResponse( + int morningBug, + int nightBug +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 12f9a841..edb99b76 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -7,7 +7,10 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.bug.BugService; +import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; import lombok.RequiredArgsConstructor; @@ -20,7 +23,13 @@ public class BugController { @GetMapping @ResponseStatus(HttpStatus.OK) - public BugResponse getBug() { - return bugService.getBug(1L); + public BugResponse getBug(@CurrentMember AuthorizationMember member) { + return bugService.getBug(member.id()); + } + + @GetMapping("/today") + @ResponseStatus(HttpStatus.OK) + public TodayBugResponse getTodayBug(@CurrentMember AuthorizationMember member) { + return bugService.getTodayBug(member.id()); } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 406bcda8..dc7b0eb3 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 8f28a0c8..3b245d88 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
+<<<<<<< HEAD
+Content-Length: 194
+=======
 Content-Length: 192
+>>>>>>> b5f9a450e8eb3b4f4527d412d519e6afc9c16365
 Host: localhost:8080
 
 {

From b55c2020e94301986b2664eedd651d5fb2353a42 Mon Sep 17 00:00:00 2001
From: Park Seyeon 
Date: Mon, 13 Nov 2023 17:23:02 +0900
Subject: [PATCH 034/185] =?UTF-8?q?Revert=20"feat:=20healthCheck=20path=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#66)"=20(#71)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This reverts commit baf4703c010e0494032d6e62e4c3f44906cfccd6.
---
 src/main/java/com/moabam/global/config/WebConfig.java | 1 -
 src/main/resources/config                             | 2 +-
 src/main/resources/static/docs/coupon.html            | 4 ----
 3 files changed, 1 insertion(+), 6 deletions(-)

diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java
index 7de73c53..166265d5 100644
--- a/src/main/java/com/moabam/global/config/WebConfig.java
+++ b/src/main/java/com/moabam/global/config/WebConfig.java
@@ -43,7 +43,6 @@ public HandlerMethodArgumentResolver handlerMethodArgumentResolver() {
 	public PathResolver pathResolver() {
 		PathResolver.Paths path = PathResolver.Paths.builder()
 			.permitAll(List.of(
-				PathMapper.parsePath("/"),
 				PathMapper.parsePath("/members"),
 				PathMapper.parsePath("/members/login/*/oauth")
 			))
diff --git a/src/main/resources/config b/src/main/resources/config
index f392e58a..73b984ec 160000
--- a/src/main/resources/config
+++ b/src/main/resources/config
@@ -1 +1 @@
-Subproject commit f392e58aefb231e765995b30c8c0194a67756b8c
+Subproject commit 73b984ec52bfcc872a0acbe4b5a038dcc1d79262
diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html
index b3b98263..89b81eb9 100644
--- a/src/main/resources/static/docs/coupon.html
+++ b/src/main/resources/static/docs/coupon.html
@@ -2132,11 +2132,7 @@ 

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-<<<<<<< HEAD
-Content-Length: 194
-=======
 Content-Length: 192
->>>>>>> b5f9a450e8eb3b4f4527d412d519e6afc9c16365
 Host: localhost:8080
 
 {

From ec1ec31e9daf0a8b3ead5c53f4eb82fd36568bf6 Mon Sep 17 00:00:00 2001
From: Park Seyeon 
Date: Mon, 13 Nov 2023 17:41:56 +0900
Subject: [PATCH 035/185] =?UTF-8?q?fix:=20config=20=EC=B5=9C=EC=8B=A0?=
 =?UTF-8?q?=ED=99=94=20(#72)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: 회원 엔티티 생성 및 테스트코드 추가

* feat: 카카오 OAuth 환경변수 추가 및 클래스 바인딩

* feat: authorization code를 받기 위한 queryString generator 추가

* feat: Authorization code의 parameter 만드는 로직 분리 및 테스트 코드 추가

* feat: 회원 가입/로그인 요청 api 및 소셜 로그인 페이지 반환

* refactor: member관련 클래스 네이밍과 폴더 위치 변경

* refactor: 로그인 페이지 요청 방식 Resttemplate -> response (redirect)하도록 변경

* style: 코드 포맷 재적용 및 사용하지 않는 클래스 삭제

* chore: config 파일 업데이트

* refactor: 테스트 코드 추가 및 코드 포맷 재적용

* refactor: 사용하지 않는 코드 제거

* refactor: CRLF -> LF로 변경

* fix: config 커밋, config 최근 커밋으로 변경

* feat: 테스트 코드 추가 및 패키지 구조 변경

* refactor: revert merge

* fix: merge confilt해결 및 예외처리 추가

* test: oauth properties가 없을 때의 테스트코드 추가

* feat: 코드리뷰에 따른 기능 분리 및 테스트 코드 변경

* fix: 테스트코드 관련 code smell 제거

* feat: Authorization grant 받기 예외 코드 및 테스트 코드 추가

* feat: Authorization Token 요청 및 반환 코드, 에러 반환 테스트 코드 추가

* refactor: AuthenticationService에서 서버에 요청보내는 로직 OAuth2AuthorizationServerRequestService로 분리

* test: 로그인 요청 테스트 코드 추가

* feat: 토큰 발급 요청 기능 테스트 코드 추가 및 RestTemplate 필드변수로 변경

* test: restTemplate 및 서비스 테스트 추가

* refactor: 에러 메세지 이름 변경

* refacotr: 변수명 및 entity default 명 변경

* feat: 토큰 정보 조회 기능 및 테스트 추가

* feat: 사용자 토큰 정보 조회 및 테스트 코드 & Resttemplate 테크트 코드 변경

* fix: encoding, formatting, tab 문제로 인한 파일 삭제 후 다시 작성

* feat: JWT 토큰 제공 서비스 및 테스트 코드 추가

* feat: 토큰 인증 코드 및 테스트 코드 작성

* feat: 로그인 및 회원가입 기능 추가

- 회원의 socialId string -> long으로 변경

* feat: 회원 로그인 테스트 코드 추가

* chore: 코드 포메팅 재 설정

* feat: config 파일 업데이트

* feat: Window용 포트 redis 포트 변경 추가

* refacotr: develop 업데이트 사항 merge

* refactor: develop 업데이트 부분 merge

* fix: TimeConfig 삭제 및 코드 스멜 변경

* refactor: 코르리뷰 반영

* chore: submodule update

* feat: 메서드 파싱 customizing 및 @CurrentMember AuthorizationMember 를 파라미터로 감지하는 조건 추가

* feat: 인가회원에 대한 객체 ThreadLocalMap에 저장하는 기능 추가

* fix: 회원 정보 Optional 정보 조회 버그 fix, socialId requiredNotNull추가 등 에러 수정

* feat: API요청 Path 및 인증에 따른 filter 추가

- PathFilter: PathResolver, WebConfig
-  AuthorizationFilter:AuthorizationService, JwtAuthenticationService, JwtProviderService, MemberService
- Member info: CurrentMember, AuthorizationMember, LoginResponse, MemberMapper, CurrentMember, PublicClaim, CurrentMemberArgumentResolver

* test: CurrentMember 테스트 support 추가

* test: authorizationfilter 및 pathfilter 테스트 추가

* test: 회원 repostiory 및 fixture 추가

* test: filter support 클랠스 추가

* test: filter support 클래스 적용

* refactor: PublicClaim 변환 책임 변경

* test: PathResolver, CurrentMemberArgumentResovler테스트 코드 추가

* fix: 모든 쿠키 secure 적용되도록 변경

* refactor: 클래스 명 변경

* refactor: webConfig Path 매핑 클래스 추가

* feat: healthcheck path 추가

* fix: config 변경

* refactor: merge 변경
---
 src/main/java/com/moabam/global/config/WebConfig.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java
index 166265d5..7de73c53 100644
--- a/src/main/java/com/moabam/global/config/WebConfig.java
+++ b/src/main/java/com/moabam/global/config/WebConfig.java
@@ -43,6 +43,7 @@ public HandlerMethodArgumentResolver handlerMethodArgumentResolver() {
 	public PathResolver pathResolver() {
 		PathResolver.Paths path = PathResolver.Paths.builder()
 			.permitAll(List.of(
+				PathMapper.parsePath("/"),
 				PathMapper.parsePath("/members"),
 				PathMapper.parsePath("/members/login/*/oauth")
 			))

From 1446062c32a1016f3344ca575989ee2985c29cf5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?=
 <31675711+HyuckJuneHong@users.noreply.github.com>
Date: Tue, 14 Nov 2023 00:01:30 +0900
Subject: [PATCH 036/185] =?UTF-8?q?refactor:=20=ED=8C=A8=ED=82=A4=EC=A7=80?=
 =?UTF-8?q?=20=EB=B6=84=EB=A6=AC=20(#73)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../{ => auth}/AuthorizationService.java      |   19 +-
 .../{ => auth}/JwtAuthenticationService.java  |    6 +-
 .../{ => auth}/JwtProviderService.java        |    4 +-
 ...uth2AuthorizationServerRequestService.java |    6 +-
 .../auth/mapper/AuthMapper.java}              |   10 +-
 .../auth/mapper}/AuthorizationMapper.java     |    8 +-
 .../auth/mapper}/PathMapper.java              |    7 +-
 .../{dto => application/bug}/BugMapper.java   |   11 +-
 .../api/application/{ => bug}/BugService.java |    8 +-
 .../coupon}/CouponMapper.java                 |    8 +-
 .../{ => coupon}/CouponService.java           |   15 +-
 .../application/{ => image}/ImageService.java |    8 +-
 .../{dto => application/item}/ItemMapper.java |    8 +-
 .../application/{ => item}/ItemService.java   |   30 +-
 .../{ => member}/MemberService.java           |   18 +-
 .../notification}/NotificationMapper.java     |    3 +-
 .../NotificationService.java                  |   14 +-
 .../product}/ProductMapper.java               |    6 +-
 .../{ => product}/ProductService.java         |    9 +-
 .../application/{ => room}/RoomService.java   |   68 +-
 .../room/mapper}/CertificationsMapper.java    |   16 +-
 .../room/mapper}/RoomMapper.java              |    8 +-
 .../room/mapper}/RoutineMapper.java           |    7 +-
 .../api/domain/{entity => bug}/Bug.java       |    3 +-
 .../{entity/enums => bug}/BugActionType.java  |    2 +-
 .../domain/{entity => bug}/BugHistory.java    |    4 +-
 .../domain/{entity/enums => bug}/BugType.java |    2 +-
 .../repository/BugHistoryRepository.java      |    4 +-
 .../api/domain/{entity => coupon}/Coupon.java |    3 +-
 .../{entity/enums => coupon}/CouponType.java  |    2 +-
 .../repository/CouponRepository.java          |    4 +-
 .../repository/CouponSearchRepository.java    |    8 +-
 .../api/domain/entity/enums/ItemCategory.java |    6 -
 .../api/domain/entity/enums/ProductType.java  |    6 -
 .../{resizedimage => image}/ImageName.java    |    2 +-
 .../{resizedimage => image}/ImageResizer.java |    2 +-
 .../{resizedimage => image}/ImageSize.java    |    2 +-
 .../{resizedimage => image}/ImageType.java    |    2 +-
 .../{resizedimage => image}/ResizedImage.java |    2 +-
 .../domain/{entity => item}/Inventory.java    |    3 +-
 .../api/domain/{entity => item}/Item.java     |    6 +-
 .../moabam/api/domain/item/ItemCategory.java  |    6 +
 .../{entity/enums => item}/ItemType.java      |    4 +-
 .../repository/InventoryRepository.java       |    4 +-
 .../repository/InventorySearchRepository.java |   12 +-
 .../{ => item}/repository/ItemRepository.java |    4 +-
 .../repository/ItemSearchRepository.java      |   10 +-
 .../api/domain/{entity => member}/Member.java |    4 +-
 .../domain/{entity/enums => member}/Role.java |    2 +-
 .../repository/MemberRepository.java          |    4 +-
 .../repository/MemberSearchRepository.java    |    8 +-
 .../domain/{entity => product}/Product.java   |    3 +-
 .../api/domain/product/ProductType.java       |    6 +
 .../repository/ProductRepository.java         |    4 +-
 .../{entity => room}/Certification.java       |    2 +-
 .../DailyMemberCertification.java             |    2 +-
 .../DailyRoomCertification.java               |    2 +-
 .../domain/{entity => room}/Participant.java  |    2 +-
 .../api/domain/{entity => room}/Room.java     |    5 +-
 .../RequireExp.java => room/RoomExp.java}     |   10 +-
 .../{entity/enums => room}/RoomType.java      |    2 +-
 .../api/domain/{entity => room}/Routine.java  |    2 +-
 .../repository/CertificationRepository.java   |    4 +-
 .../CertificationsSearchRepository.java       |   16 +-
 .../DailyMemberCertificationRepository.java   |    4 +-
 .../DailyRoomCertificationRepository.java     |    4 +-
 .../repository/ParticipantRepository.java     |    4 +-
 .../ParticipantSearchRepository.java          |    8 +-
 .../{ => room}/repository/RoomRepository.java |    4 +-
 .../repository/RoutineRepository.java         |    4 +-
 .../repository/RoutineSearchRepository.java   |    6 +-
 .../{ => auth}/AuthorizationCodeRequest.java  |    2 +-
 .../{ => auth}/AuthorizationCodeResponse.java |    2 +-
 .../AuthorizationTokenInfoResponse.java       |    2 +-
 .../{ => auth}/AuthorizationTokenRequest.java |    2 +-
 .../AuthorizationTokenResponse.java           |    2 +-
 .../api/dto/{ => auth}/LoginResponse.java     |    3 +-
 .../moabam/api/dto/{ => bug}/BugResponse.java |    2 +-
 .../api/dto/{ => coupon}/CouponResponse.java  |    4 +-
 .../dto/{ => coupon}/CouponSearchRequest.java |    2 +-
 .../dto/{ => coupon}/CreateCouponRequest.java |    2 +-
 .../api/dto/{ => item}/ItemResponse.java      |    2 +-
 .../api/dto/{ => item}/ItemsResponse.java     |    2 +-
 .../dto/{ => item}/PurchaseItemRequest.java   |    4 +-
 .../KnockNotificationStatusResponse.java      |    2 +-
 .../dto/{ => product}/ProductResponse.java    |    2 +-
 .../dto/{ => product}/ProductsResponse.java   |    2 +-
 .../CertificationImageResponse.java           |    2 +-
 .../api/dto/{ => room}/CreateRoomRequest.java |    4 +-
 .../api/dto/{ => room}/EnterRoomRequest.java  |    2 +-
 .../api/dto/{ => room}/ModifyRoomRequest.java |    2 +-
 .../dto/{ => room}/RoomDetailsResponse.java   |    2 +-
 .../api/dto/{ => room}/RoutineResponse.java   |    2 +-
 .../TodayCertificateRankResponse.java         |    4 +-
 .../redis}/NotificationRepository.java        |    4 +-
 .../redis}/StringRedisRepository.java         |    2 +-
 .../api/presentation/BugController.java       |    4 +-
 .../api/presentation/CouponController.java    |    8 +-
 .../api/presentation/ItemController.java      |    8 +-
 .../api/presentation/MemberController.java    |   10 +-
 .../presentation/NotificationController.java  |    4 +-
 .../api/presentation/ProductController.java   |    4 +-
 .../api/presentation/RoomController.java      |   10 +-
 .../annotation/CurrentMember.java             |    2 +-
 .../annotation/MemberTest.java                |    2 +-
 .../filter/AuthorizationFilter.java           |   12 +-
 .../global/{ => auth}/filter/PathFilter.java  |    6 +-
 .../CurrentMemberArgumentResolver.java        |    8 +-
 .../handler/PathResolver.java                 |    4 +-
 .../auth/model}/AuthorizationMember.java      |    4 +-
 .../model}/AuthorizationThreadLocal.java      |    4 +-
 .../auth/model}/PublicClaim.java              |    4 +-
 .../com/moabam/global/config/WebConfig.java   |    6 +-
 src/main/resources/static/docs/coupon.html    | 2787 ++++-------------
 src/main/resources/static/docs/index.html     |    2 +-
 .../resources/static/docs/notification.html   | 2605 +++------------
 .../application/AuthorizationServiceTest.java |   20 +-
 .../api/application/BugServiceTest.java       |    8 +-
 .../api/application/CouponServiceTest.java    |   15 +-
 .../api/application/ImageServiceTest.java     |    5 +-
 .../api/application/ItemServiceTest.java      |   30 +-
 .../JwtAuthenticationServiceTest.java         |    4 +-
 .../application/JwtProviderServiceTest.java   |    3 +-
 .../api/application/MemberServiceTest.java    |    9 +-
 .../application/NotificationServiceTest.java  |   12 +-
 ...AuthorizationServerRequestServiceTest.java |    5 +-
 .../api/application/ProductServiceTest.java   |    9 +-
 .../api/application/RoomServiceTest.java      |   39 +-
 .../com/moabam/api/domain/entity/BugTest.java |    3 +-
 .../api/domain/entity/CertificationTest.java  |    5 +-
 .../moabam/api/domain/entity/CouponTest.java  |    3 +-
 .../moabam/api/domain/entity/ItemTest.java    |    3 +-
 .../moabam/api/domain/entity/MemberTest.java  |    4 +-
 .../moabam/api/domain/entity/ProductTest.java |    1 +
 .../moabam/api/domain/entity/RoomTest.java    |    3 +-
 .../domain/entity/enums/CouponTypeTest.java   |    1 +
 .../CertificationsSearchRepositoryTest.java   |   21 +-
 .../CouponSearchRepositoryTest.java           |    6 +-
 .../InventorySearchRepositoryTest.java        |   12 +-
 .../repository/ItemSearchRepositoryTest.java  |    7 +-
 .../repository/MemberRepositoryTest.java      |    3 +-
 .../NotificationRepositoryTest.java           |    3 +-
 .../ParticipantSearchRepositoryTest.java      |    7 +-
 .../api/dto/CreateCouponRequestTest.java      |    1 +
 .../api/presentation/BugControllerTest.java   |    6 +-
 .../presentation/CouponControllerTest.java    |   12 +-
 .../api/presentation/ItemControllerTest.java  |   14 +-
 .../presentation/MemberControllerTest.java    |   10 +-
 .../NotificationControllerTest.java           |   12 +-
 .../presentation/ProductControllerTest.java   |    8 +-
 .../api/presentation/RoomControllerTest.java  |   36 +-
 .../CurrentMemberArgumentResolverTest.java    |   11 +-
 .../common/handler/PathResolverTest.java      |    4 +-
 .../repository/StringRedisRepositoryTest.java |    1 +
 .../filter/AuthorizationFilterTest.java       |   13 +-
 .../moabam/global/filter/PathFilterTest.java  |    3 +-
 .../moabam/support/annotation/WithMember.java |    2 +-
 .../common/FilterProcessExtension.java        |    4 +-
 .../support/common/WithFilterSupporter.java   |    2 +-
 .../common/WithoutFilterSupporter.java        |    8 +-
 .../support/config/TestQuerydslConfig.java    |    6 +-
 .../fixture/AuthorizationResponseFixture.java |    6 +-
 .../moabam/support/fixture/BugFixture.java    |    2 +-
 .../moabam/support/fixture/CouponFixture.java |    8 +-
 .../support/fixture/InventoryFixture.java     |    4 +-
 .../moabam/support/fixture/ItemFixture.java   |    6 +-
 .../support/fixture/JwtProviderFixture.java   |    2 +-
 .../moabam/support/fixture/MemberFixture.java |    2 +-
 .../support/fixture/ParticipantFixture.java   |    4 +-
 .../support/fixture/ProductFixture.java       |    4 +-
 .../support/fixture/PublicClaimFixture.java   |    4 +-
 .../moabam/support/fixture/RoomFixture.java   |   14 +-
 172 files changed, 1624 insertions(+), 4893 deletions(-)
 rename src/main/java/com/moabam/api/application/{ => auth}/AuthorizationService.java (90%)
 rename src/main/java/com/moabam/api/application/{ => auth}/JwtAuthenticationService.java (87%)
 rename src/main/java/com/moabam/api/application/{ => auth}/JwtProviderService.java (93%)
 rename src/main/java/com/moabam/api/application/{ => auth}/OAuth2AuthorizationServerRequestService.java (93%)
 rename src/main/java/com/moabam/api/{dto/MemberMapper.java => application/auth/mapper/AuthMapper.java} (69%)
 rename src/main/java/com/moabam/api/{dto => application/auth/mapper}/AuthorizationMapper.java (81%)
 rename src/main/java/com/moabam/api/{dto => application/auth/mapper}/PathMapper.java (87%)
 rename src/main/java/com/moabam/api/{dto => application/bug}/BugMapper.java (69%)
 rename src/main/java/com/moabam/api/application/{ => bug}/BugService.java (70%)
 rename src/main/java/com/moabam/api/{dto => application/coupon}/CouponMapper.java (80%)
 rename src/main/java/com/moabam/api/application/{ => coupon}/CouponService.java (84%)
 rename src/main/java/com/moabam/api/application/{ => image}/ImageService.java (87%)
 rename src/main/java/com/moabam/api/{dto => application/item}/ItemMapper.java (82%)
 rename src/main/java/com/moabam/api/application/{ => item}/ItemService.java (76%)
 rename src/main/java/com/moabam/api/application/{ => member}/MemberService.java (75%)
 rename src/main/java/com/moabam/api/{dto => application/notification}/NotificationMapper.java (91%)
 rename src/main/java/com/moabam/api/application/{ => notification}/NotificationService.java (90%)
 rename src/main/java/com/moabam/api/{dto => application/product}/ProductMapper.java (78%)
 rename src/main/java/com/moabam/api/application/{ => product}/ProductService.java (68%)
 rename src/main/java/com/moabam/api/application/{ => room}/RoomService.java (87%)
 rename src/main/java/com/moabam/api/{dto => application/room/mapper}/CertificationsMapper.java (82%)
 rename src/main/java/com/moabam/api/{dto => application/room/mapper}/RoomMapper.java (82%)
 rename src/main/java/com/moabam/api/{dto => application/room/mapper}/RoutineMapper.java (78%)
 rename src/main/java/com/moabam/api/domain/{entity => bug}/Bug.java (95%)
 rename src/main/java/com/moabam/api/domain/{entity/enums => bug}/BugActionType.java (62%)
 rename src/main/java/com/moabam/api/domain/{entity => bug}/BugHistory.java (92%)
 rename src/main/java/com/moabam/api/domain/{entity/enums => bug}/BugType.java (72%)
 rename src/main/java/com/moabam/api/domain/{ => bug}/repository/BugHistoryRepository.java (61%)
 rename src/main/java/com/moabam/api/domain/{entity => coupon}/Coupon.java (96%)
 rename src/main/java/com/moabam/api/domain/{entity/enums => coupon}/CouponType.java (95%)
 rename src/main/java/com/moabam/api/domain/{ => coupon}/repository/CouponRepository.java (65%)
 rename src/main/java/com/moabam/api/domain/{ => coupon}/repository/CouponSearchRepository.java (91%)
 delete mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java
 delete mode 100644 src/main/java/com/moabam/api/domain/entity/enums/ProductType.java
 rename src/main/java/com/moabam/api/domain/{resizedimage => image}/ImageName.java (95%)
 rename src/main/java/com/moabam/api/domain/{resizedimage => image}/ImageResizer.java (98%)
 rename src/main/java/com/moabam/api/domain/{resizedimage => image}/ImageSize.java (84%)
 rename src/main/java/com/moabam/api/domain/{resizedimage => image}/ImageType.java (61%)
 rename src/main/java/com/moabam/api/domain/{resizedimage => image}/ResizedImage.java (96%)
 rename src/main/java/com/moabam/api/domain/{entity => item}/Inventory.java (94%)
 rename src/main/java/com/moabam/api/domain/{entity => item}/Item.java (93%)
 create mode 100644 src/main/java/com/moabam/api/domain/item/ItemCategory.java
 rename src/main/java/com/moabam/api/domain/{entity/enums => item}/ItemType.java (83%)
 rename src/main/java/com/moabam/api/domain/{ => item}/repository/InventoryRepository.java (61%)
 rename src/main/java/com/moabam/api/domain/{ => item}/repository/InventorySearchRepository.java (82%)
 rename src/main/java/com/moabam/api/domain/{ => item}/repository/ItemRepository.java (60%)
 rename src/main/java/com/moabam/api/domain/{ => item}/repository/ItemSearchRepository.java (79%)
 rename src/main/java/com/moabam/api/domain/{entity => member}/Member.java (97%)
 rename src/main/java/com/moabam/api/domain/{entity/enums => member}/Role.java (50%)
 rename src/main/java/com/moabam/api/domain/{ => member}/repository/MemberRepository.java (69%)
 rename src/main/java/com/moabam/api/domain/{ => member}/repository/MemberSearchRepository.java (74%)
 rename src/main/java/com/moabam/api/domain/{entity => product}/Product.java (95%)
 create mode 100644 src/main/java/com/moabam/api/domain/product/ProductType.java
 rename src/main/java/com/moabam/api/domain/{ => product}/repository/ProductRepository.java (59%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/Certification.java (97%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/DailyMemberCertification.java (97%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/DailyRoomCertification.java (96%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/Participant.java (98%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/Room.java (96%)
 rename src/main/java/com/moabam/api/domain/{entity/enums/RequireExp.java => room/RoomExp.java} (75%)
 rename src/main/java/com/moabam/api/domain/{entity/enums => room}/RoomType.java (50%)
 rename src/main/java/com/moabam/api/domain/{entity => room}/Routine.java (97%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/CertificationRepository.java (61%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/CertificationsSearchRepository.java (82%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/DailyMemberCertificationRepository.java (62%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/DailyRoomCertificationRepository.java (62%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/ParticipantRepository.java (61%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/ParticipantSearchRepository.java (87%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/RoomRepository.java (60%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/RoutineRepository.java (61%)
 rename src/main/java/com/moabam/api/domain/{ => room}/repository/RoutineSearchRepository.java (76%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/AuthorizationCodeRequest.java (94%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/AuthorizationCodeResponse.java (78%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/AuthorizationTokenInfoResponse.java (87%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/AuthorizationTokenRequest.java (94%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/AuthorizationTokenResponse.java (93%)
 rename src/main/java/com/moabam/api/dto/{ => auth}/LoginResponse.java (69%)
 rename src/main/java/com/moabam/api/dto/{ => bug}/BugResponse.java (78%)
 rename src/main/java/com/moabam/api/dto/{ => coupon}/CouponResponse.java (83%)
 rename src/main/java/com/moabam/api/dto/{ => coupon}/CouponSearchRequest.java (80%)
 rename src/main/java/com/moabam/api/dto/{ => coupon}/CreateCouponRequest.java (97%)
 rename src/main/java/com/moabam/api/dto/{ => item}/ItemResponse.java (85%)
 rename src/main/java/com/moabam/api/dto/{ => item}/ItemsResponse.java (83%)
 rename src/main/java/com/moabam/api/dto/{ => item}/PurchaseItemRequest.java (59%)
 rename src/main/java/com/moabam/api/dto/{ => notification}/KnockNotificationStatusResponse.java (80%)
 rename src/main/java/com/moabam/api/dto/{ => product}/ProductResponse.java (79%)
 rename src/main/java/com/moabam/api/dto/{ => product}/ProductsResponse.java (78%)
 rename src/main/java/com/moabam/api/dto/{ => room}/CertificationImageResponse.java (77%)
 rename src/main/java/com/moabam/api/dto/{ => room}/CreateRoomRequest.java (88%)
 rename src/main/java/com/moabam/api/dto/{ => room}/EnterRoomRequest.java (81%)
 rename src/main/java/com/moabam/api/dto/{ => room}/ModifyRoomRequest.java (95%)
 rename src/main/java/com/moabam/api/dto/{ => room}/RoomDetailsResponse.java (93%)
 rename src/main/java/com/moabam/api/dto/{ => room}/RoutineResponse.java (76%)
 rename src/main/java/com/moabam/api/dto/{ => room}/TodayCertificateRankResponse.java (75%)
 rename src/main/java/com/moabam/api/{domain/repository => infrastructure/redis}/NotificationRepository.java (93%)
 rename src/main/java/com/moabam/{global/common/repository => api/infrastructure/redis}/StringRedisRepository.java (94%)
 rename src/main/java/com/moabam/global/{common => auth}/annotation/CurrentMember.java (85%)
 rename src/main/java/com/moabam/global/{common => auth}/annotation/MemberTest.java (59%)
 rename src/main/java/com/moabam/global/{ => auth}/filter/AuthorizationFilter.java (91%)
 rename src/main/java/com/moabam/global/{ => auth}/filter/PathFilter.java (84%)
 rename src/main/java/com/moabam/global/{common => auth}/handler/CurrentMemberArgumentResolver.java (80%)
 rename src/main/java/com/moabam/global/{common => auth}/handler/PathResolver.java (96%)
 rename src/main/java/com/moabam/{api/dto => global/auth/model}/AuthorizationMember.java (50%)
 rename src/main/java/com/moabam/global/{common/util => auth/model}/AuthorizationThreadLocal.java (87%)
 rename src/main/java/com/moabam/{api/dto => global/auth/model}/PublicClaim.java (69%)

diff --git a/src/main/java/com/moabam/api/application/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
similarity index 90%
rename from src/main/java/com/moabam/api/application/AuthorizationService.java
rename to src/main/java/com/moabam/api/application/auth/AuthorizationService.java
index 8f2db0e3..ac130cc1 100644
--- a/src/main/java/com/moabam/api/application/AuthorizationService.java
+++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.auth;
 
 import org.springframework.http.ResponseEntity;
 import org.springframework.stereotype.Service;
@@ -7,14 +7,15 @@
 import org.springframework.util.MultiValueMap;
 import org.springframework.web.util.UriComponentsBuilder;
 
-import com.moabam.api.dto.AuthorizationCodeRequest;
-import com.moabam.api.dto.AuthorizationCodeResponse;
-import com.moabam.api.dto.AuthorizationMapper;
-import com.moabam.api.dto.AuthorizationTokenInfoResponse;
-import com.moabam.api.dto.AuthorizationTokenRequest;
-import com.moabam.api.dto.AuthorizationTokenResponse;
-import com.moabam.api.dto.LoginResponse;
-import com.moabam.api.dto.PublicClaim;
+import com.moabam.api.application.auth.mapper.AuthorizationMapper;
+import com.moabam.api.application.member.MemberService;
+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.global.auth.model.PublicClaim;
 import com.moabam.global.common.util.CookieUtils;
 import com.moabam.global.common.util.GlobalConstant;
 import com.moabam.global.config.OAuthConfig;
diff --git a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
similarity index 87%
rename from src/main/java/com/moabam/api/application/JwtAuthenticationService.java
rename to src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
index 4956e397..f43c4aa3 100644
--- a/src/main/java/com/moabam/api/application/JwtAuthenticationService.java
+++ b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
@@ -1,12 +1,12 @@
-package com.moabam.api.application;
+package com.moabam.api.application.auth;
 
 import java.util.Base64;
 
 import org.json.JSONObject;
 import org.springframework.stereotype.Service;
 
-import com.moabam.api.dto.AuthorizationMapper;
-import com.moabam.api.dto.PublicClaim;
+import com.moabam.api.application.auth.mapper.AuthorizationMapper;
+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;
diff --git a/src/main/java/com/moabam/api/application/JwtProviderService.java b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
similarity index 93%
rename from src/main/java/com/moabam/api/application/JwtProviderService.java
rename to src/main/java/com/moabam/api/application/auth/JwtProviderService.java
index 1ea2735b..4ea924dd 100644
--- a/src/main/java/com/moabam/api/application/JwtProviderService.java
+++ b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
@@ -1,10 +1,10 @@
-package com.moabam.api.application;
+package com.moabam.api.application.auth;
 
 import java.util.Date;
 
 import org.springframework.stereotype.Service;
 
-import com.moabam.api.dto.PublicClaim;
+import com.moabam.global.auth.model.PublicClaim;
 import com.moabam.global.config.TokenConfig;
 
 import io.jsonwebtoken.JwtBuilder;
diff --git a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java
similarity index 93%
rename from src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java
rename to src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java
index 78c3c278..95964681 100644
--- a/src/main/java/com/moabam/api/application/OAuth2AuthorizationServerRequestService.java
+++ b/src/main/java/com/moabam/api/application/auth/OAuth2AuthorizationServerRequestService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.auth;
 
 import java.io.IOException;
 
@@ -12,8 +12,8 @@
 import org.springframework.util.MultiValueMap;
 import org.springframework.web.client.RestTemplate;
 
-import com.moabam.api.dto.AuthorizationTokenInfoResponse;
-import com.moabam.api.dto.AuthorizationTokenResponse;
+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;
diff --git a/src/main/java/com/moabam/api/dto/MemberMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java
similarity index 69%
rename from src/main/java/com/moabam/api/dto/MemberMapper.java
rename to src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java
index 84646532..f1b3b8a6 100644
--- a/src/main/java/com/moabam/api/dto/MemberMapper.java
+++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java
@@ -1,13 +1,15 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.auth.mapper;
 
-import com.moabam.api.domain.entity.Bug;
-import com.moabam.api.domain.entity.Member;
+import com.moabam.api.domain.bug.Bug;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.dto.auth.LoginResponse;
+import com.moabam.global.auth.model.PublicClaim;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
 
 @NoArgsConstructor(access = AccessLevel.PRIVATE)
-public final class MemberMapper {
+public final class AuthMapper {
 
 	public static Member toMember(Long socialId, String nickName) {
 		return Member.builder()
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java
similarity index 81%
rename from src/main/java/com/moabam/api/dto/AuthorizationMapper.java
rename to src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java
index 8b6ceeae..d2784108 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationMapper.java
+++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java
@@ -1,8 +1,12 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.auth.mapper;
 
 import org.json.JSONObject;
 
-import com.moabam.api.domain.entity.enums.Role;
+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.AuthorizationMember;
+import com.moabam.global.auth.model.PublicClaim;
 import com.moabam.global.config.OAuthConfig;
 
 import lombok.AccessLevel;
diff --git a/src/main/java/com/moabam/api/dto/PathMapper.java b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java
similarity index 87%
rename from src/main/java/com/moabam/api/dto/PathMapper.java
rename to src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java
index 941fc731..2aa36005 100644
--- a/src/main/java/com/moabam/api/dto/PathMapper.java
+++ b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.auth.mapper;
 
 import static java.util.Objects.*;
 
@@ -6,8 +6,8 @@
 
 import org.springframework.http.HttpMethod;
 
-import com.moabam.api.domain.entity.enums.Role;
-import com.moabam.global.common.handler.PathResolver;
+import com.moabam.api.domain.member.Role;
+import com.moabam.global.auth.handler.PathResolver;
 
 import jakarta.annotation.Nonnull;
 import lombok.AccessLevel;
@@ -24,6 +24,7 @@ public static  PathResolver.Path parsePath(String uri, @Nonnull List param
 		if (!params.isEmpty() && params.get(0) instanceof Role) {
 			return parsePath(uri, (List)params, null);
 		}
+
 		return parsePath(uri, null, (List)params);
 	}
 
diff --git a/src/main/java/com/moabam/api/dto/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java
similarity index 69%
rename from src/main/java/com/moabam/api/dto/BugMapper.java
rename to src/main/java/com/moabam/api/application/bug/BugMapper.java
index 1c86f990..0d439458 100644
--- a/src/main/java/com/moabam/api/dto/BugMapper.java
+++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java
@@ -1,9 +1,10 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.bug;
 
-import com.moabam.api.domain.entity.Bug;
-import com.moabam.api.domain.entity.BugHistory;
-import com.moabam.api.domain.entity.enums.BugActionType;
-import com.moabam.api.domain.entity.enums.BugType;
+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.BugResponse;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/application/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java
similarity index 70%
rename from src/main/java/com/moabam/api/application/BugService.java
rename to src/main/java/com/moabam/api/application/bug/BugService.java
index 74f98623..880eb8f9 100644
--- a/src/main/java/com/moabam/api/application/BugService.java
+++ b/src/main/java/com/moabam/api/application/bug/BugService.java
@@ -1,11 +1,11 @@
-package com.moabam.api.application;
+package com.moabam.api.application.bug;
 
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.moabam.api.domain.entity.Member;
-import com.moabam.api.dto.BugMapper;
-import com.moabam.api.dto.BugResponse;
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.domain.member.Member;
+import com.moabam.api.dto.bug.BugResponse;
 
 import lombok.RequiredArgsConstructor;
 
diff --git a/src/main/java/com/moabam/api/dto/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java
similarity index 80%
rename from src/main/java/com/moabam/api/dto/CouponMapper.java
rename to src/main/java/com/moabam/api/application/coupon/CouponMapper.java
index a788d3d5..41a361ef 100644
--- a/src/main/java/com/moabam/api/dto/CouponMapper.java
+++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java
@@ -1,7 +1,9 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.coupon;
 
-import com.moabam.api.domain.entity.Coupon;
-import com.moabam.api.domain.entity.enums.CouponType;
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.domain.coupon.CouponType;
+import com.moabam.api.dto.coupon.CouponResponse;
+import com.moabam.api.dto.coupon.CreateCouponRequest;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/application/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java
similarity index 84%
rename from src/main/java/com/moabam/api/application/CouponService.java
rename to src/main/java/com/moabam/api/application/coupon/CouponService.java
index db43a892..88cca6f2 100644
--- a/src/main/java/com/moabam/api/application/CouponService.java
+++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.coupon;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -6,13 +6,12 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.moabam.api.domain.entity.Coupon;
-import com.moabam.api.domain.repository.CouponRepository;
-import com.moabam.api.domain.repository.CouponSearchRepository;
-import com.moabam.api.dto.CouponMapper;
-import com.moabam.api.dto.CouponResponse;
-import com.moabam.api.dto.CouponSearchRequest;
-import com.moabam.api.dto.CreateCouponRequest;
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.domain.coupon.repository.CouponRepository;
+import com.moabam.api.domain.coupon.repository.CouponSearchRepository;
+import com.moabam.api.dto.coupon.CouponResponse;
+import com.moabam.api.dto.coupon.CouponSearchRequest;
+import com.moabam.api.dto.coupon.CreateCouponRequest;
 import com.moabam.global.error.exception.BadRequestException;
 import com.moabam.global.error.exception.ConflictException;
 import com.moabam.global.error.exception.NotFoundException;
diff --git a/src/main/java/com/moabam/api/application/ImageService.java b/src/main/java/com/moabam/api/application/image/ImageService.java
similarity index 87%
rename from src/main/java/com/moabam/api/application/ImageService.java
rename to src/main/java/com/moabam/api/application/image/ImageService.java
index 0fdaac6d..c2b4208f 100644
--- a/src/main/java/com/moabam/api/application/ImageService.java
+++ b/src/main/java/com/moabam/api/application/image/ImageService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.image;
 
 import java.util.ArrayList;
 import java.util.List;
@@ -7,9 +7,9 @@
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
 
-import com.moabam.api.domain.resizedimage.ImageName;
-import com.moabam.api.domain.resizedimage.ImageResizer;
-import com.moabam.api.domain.resizedimage.ImageType;
+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.infrastructure.s3.S3Manager;
 
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/dto/ItemMapper.java b/src/main/java/com/moabam/api/application/item/ItemMapper.java
similarity index 82%
rename from src/main/java/com/moabam/api/dto/ItemMapper.java
rename to src/main/java/com/moabam/api/application/item/ItemMapper.java
index 5b2ffe5f..7499d561 100644
--- a/src/main/java/com/moabam/api/dto/ItemMapper.java
+++ b/src/main/java/com/moabam/api/application/item/ItemMapper.java
@@ -1,9 +1,11 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.item;
 
 import java.util.List;
 
-import com.moabam.api.domain.entity.Inventory;
-import com.moabam.api.domain.entity.Item;
+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;
diff --git a/src/main/java/com/moabam/api/application/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java
similarity index 76%
rename from src/main/java/com/moabam/api/application/ItemService.java
rename to src/main/java/com/moabam/api/application/item/ItemService.java
index 66a8bb92..d1d278aa 100644
--- a/src/main/java/com/moabam/api/application/ItemService.java
+++ b/src/main/java/com/moabam/api/application/item/ItemService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.item;
 
 import static com.moabam.global.error.model.ErrorMessage.*;
 
@@ -7,20 +7,20 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.moabam.api.domain.entity.Bug;
-import com.moabam.api.domain.entity.Inventory;
-import com.moabam.api.domain.entity.Item;
-import com.moabam.api.domain.entity.Member;
-import com.moabam.api.domain.entity.enums.ItemType;
-import com.moabam.api.domain.repository.BugHistoryRepository;
-import com.moabam.api.domain.repository.InventoryRepository;
-import com.moabam.api.domain.repository.InventorySearchRepository;
-import com.moabam.api.domain.repository.ItemRepository;
-import com.moabam.api.domain.repository.ItemSearchRepository;
-import com.moabam.api.dto.BugMapper;
-import com.moabam.api.dto.ItemMapper;
-import com.moabam.api.dto.ItemsResponse;
-import com.moabam.api.dto.PurchaseItemRequest;
+import com.moabam.api.application.bug.BugMapper;
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.domain.bug.Bug;
+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.ItemsResponse;
+import com.moabam.api.dto.item.PurchaseItemRequest;
 import com.moabam.global.error.exception.ConflictException;
 import com.moabam.global.error.exception.NotFoundException;
 
diff --git a/src/main/java/com/moabam/api/application/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java
similarity index 75%
rename from src/main/java/com/moabam/api/application/MemberService.java
rename to src/main/java/com/moabam/api/application/member/MemberService.java
index ca37cdea..7e984867 100644
--- a/src/main/java/com/moabam/api/application/MemberService.java
+++ b/src/main/java/com/moabam/api/application/member/MemberService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.member;
 
 import static com.moabam.global.error.model.ErrorMessage.*;
 
@@ -10,12 +10,12 @@
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.moabam.api.domain.entity.Member;
-import com.moabam.api.domain.repository.MemberRepository;
-import com.moabam.api.domain.repository.MemberSearchRepository;
-import com.moabam.api.dto.AuthorizationTokenInfoResponse;
-import com.moabam.api.dto.LoginResponse;
-import com.moabam.api.dto.MemberMapper;
+import com.moabam.api.application.auth.mapper.AuthMapper;
+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.dto.auth.AuthorizationTokenInfoResponse;
+import com.moabam.api.dto.auth.LoginResponse;
 import com.moabam.global.error.exception.NotFoundException;
 
 import lombok.RequiredArgsConstructor;
@@ -38,12 +38,12 @@ public LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfo
 		Optional member = memberRepository.findBySocialId(authorizationTokenInfoResponse.id());
 		Member loginMember = member.orElseGet(() -> signUp(authorizationTokenInfoResponse.id()));
 
-		return MemberMapper.toLoginResponse(loginMember, member.isEmpty());
+		return AuthMapper.toLoginResponse(loginMember, member.isEmpty());
 	}
 
 	private Member signUp(Long socialId) {
 		String randomNickName = createRandomNickName();
-		Member member = MemberMapper.toMember(socialId, randomNickName);
+		Member member = AuthMapper.toMember(socialId, randomNickName);
 
 		return memberRepository.save(member);
 	}
diff --git a/src/main/java/com/moabam/api/dto/NotificationMapper.java b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java
similarity index 91%
rename from src/main/java/com/moabam/api/dto/NotificationMapper.java
rename to src/main/java/com/moabam/api/application/notification/NotificationMapper.java
index e3c2b477..b18156e2 100644
--- a/src/main/java/com/moabam/api/dto/NotificationMapper.java
+++ b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java
@@ -1,9 +1,10 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.notification;
 
 import java.util.List;
 
 import com.google.firebase.messaging.Message;
 import com.google.firebase.messaging.Notification;
+import com.moabam.api.dto.notification.KnockNotificationStatusResponse;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/application/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java
similarity index 90%
rename from src/main/java/com/moabam/api/application/NotificationService.java
rename to src/main/java/com/moabam/api/application/notification/NotificationService.java
index 12c3ddf9..0845042f 100644
--- a/src/main/java/com/moabam/api/application/NotificationService.java
+++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java
@@ -1,4 +1,4 @@
-package com.moabam.api.application;
+package com.moabam.api.application.notification;
 
 import static com.moabam.global.common.util.GlobalConstant.*;
 
@@ -15,12 +15,12 @@
 import com.google.firebase.messaging.FirebaseMessaging;
 import com.google.firebase.messaging.Message;
 import com.google.firebase.messaging.Notification;
-import com.moabam.api.domain.entity.Participant;
-import com.moabam.api.domain.repository.NotificationRepository;
-import com.moabam.api.domain.repository.ParticipantSearchRepository;
-import com.moabam.api.dto.KnockNotificationStatusResponse;
-import com.moabam.api.dto.NotificationMapper;
-import com.moabam.global.common.annotation.MemberTest;
+import com.moabam.api.application.room.RoomService;
+import com.moabam.api.domain.room.Participant;
+import com.moabam.api.domain.room.repository.ParticipantSearchRepository;
+import com.moabam.api.dto.notification.KnockNotificationStatusResponse;
+import com.moabam.api.infrastructure.redis.NotificationRepository;
+import com.moabam.global.auth.annotation.MemberTest;
 import com.moabam.global.error.exception.ConflictException;
 import com.moabam.global.error.exception.NotFoundException;
 import com.moabam.global.error.model.ErrorMessage;
diff --git a/src/main/java/com/moabam/api/dto/ProductMapper.java b/src/main/java/com/moabam/api/application/product/ProductMapper.java
similarity index 78%
rename from src/main/java/com/moabam/api/dto/ProductMapper.java
rename to src/main/java/com/moabam/api/application/product/ProductMapper.java
index bd2fce60..6207cfc0 100644
--- a/src/main/java/com/moabam/api/dto/ProductMapper.java
+++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java
@@ -1,8 +1,10 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.product;
 
 import java.util.List;
 
-import com.moabam.api.domain.entity.Product;
+import com.moabam.api.domain.product.Product;
+import com.moabam.api.dto.product.ProductResponse;
+import com.moabam.api.dto.product.ProductsResponse;
 import com.moabam.global.common.util.StreamUtils;
 
 import lombok.AccessLevel;
diff --git a/src/main/java/com/moabam/api/application/ProductService.java b/src/main/java/com/moabam/api/application/product/ProductService.java
similarity index 68%
rename from src/main/java/com/moabam/api/application/ProductService.java
rename to src/main/java/com/moabam/api/application/product/ProductService.java
index 677d840d..d5d389f3 100644
--- a/src/main/java/com/moabam/api/application/ProductService.java
+++ b/src/main/java/com/moabam/api/application/product/ProductService.java
@@ -1,14 +1,13 @@
-package com.moabam.api.application;
+package com.moabam.api.application.product;
 
 import java.util.List;
 
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import com.moabam.api.domain.entity.Product;
-import com.moabam.api.domain.repository.ProductRepository;
-import com.moabam.api.dto.ProductMapper;
-import com.moabam.api.dto.ProductsResponse;
+import com.moabam.api.domain.product.Product;
+import com.moabam.api.domain.product.repository.ProductRepository;
+import com.moabam.api.dto.product.ProductsResponse;
 
 import lombok.RequiredArgsConstructor;
 
diff --git a/src/main/java/com/moabam/api/application/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java
similarity index 87%
rename from src/main/java/com/moabam/api/application/RoomService.java
rename to src/main/java/com/moabam/api/application/room/RoomService.java
index 598eeb84..7b542fa1 100644
--- a/src/main/java/com/moabam/api/application/RoomService.java
+++ b/src/main/java/com/moabam/api/application/room/RoomService.java
@@ -1,7 +1,7 @@
-package com.moabam.api.application;
+package com.moabam.api.application.room;
 
-import static com.moabam.api.domain.entity.enums.RoomType.*;
-import static com.moabam.api.domain.resizedimage.ImageType.*;
+import static com.moabam.api.domain.image.ImageType.*;
+import static com.moabam.api.domain.room.RoomType.*;
 import static com.moabam.global.error.model.ErrorMessage.*;
 
 import java.time.LocalDate;
@@ -18,35 +18,37 @@
 import org.springframework.transaction.annotation.Transactional;
 import org.springframework.web.multipart.MultipartFile;
 
-import com.moabam.api.domain.entity.Certification;
-import com.moabam.api.domain.entity.DailyMemberCertification;
-import com.moabam.api.domain.entity.DailyRoomCertification;
-import com.moabam.api.domain.entity.Member;
-import com.moabam.api.domain.entity.Participant;
-import com.moabam.api.domain.entity.Room;
-import com.moabam.api.domain.entity.Routine;
-import com.moabam.api.domain.entity.enums.BugType;
-import com.moabam.api.domain.entity.enums.RequireExp;
-import com.moabam.api.domain.entity.enums.RoomType;
-import com.moabam.api.domain.repository.CertificationRepository;
-import com.moabam.api.domain.repository.CertificationsSearchRepository;
-import com.moabam.api.domain.repository.DailyMemberCertificationRepository;
-import com.moabam.api.domain.repository.DailyRoomCertificationRepository;
-import com.moabam.api.domain.repository.ParticipantRepository;
-import com.moabam.api.domain.repository.ParticipantSearchRepository;
-import com.moabam.api.domain.repository.RoomRepository;
-import com.moabam.api.domain.repository.RoutineRepository;
-import com.moabam.api.domain.repository.RoutineSearchRepository;
-import com.moabam.api.dto.CertificationImageResponse;
-import com.moabam.api.dto.CertificationsMapper;
-import com.moabam.api.dto.CreateRoomRequest;
-import com.moabam.api.dto.EnterRoomRequest;
-import com.moabam.api.dto.ModifyRoomRequest;
-import com.moabam.api.dto.RoomDetailsResponse;
-import com.moabam.api.dto.RoomMapper;
-import com.moabam.api.dto.RoutineMapper;
-import com.moabam.api.dto.RoutineResponse;
-import com.moabam.api.dto.TodayCertificateRankResponse;
+import com.moabam.api.application.image.ImageService;
+import com.moabam.api.application.member.MemberService;
+import com.moabam.api.application.room.mapper.CertificationsMapper;
+import com.moabam.api.application.room.mapper.RoomMapper;
+import com.moabam.api.application.room.mapper.RoutineMapper;
+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.RoomType;
+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.domain.room.repository.RoutineSearchRepository;
+import com.moabam.api.dto.room.CertificationImageResponse;
+import com.moabam.api.dto.room.CreateRoomRequest;
+import com.moabam.api.dto.room.EnterRoomRequest;
+import com.moabam.api.dto.room.ModifyRoomRequest;
+import com.moabam.api.dto.room.RoomDetailsResponse;
+import com.moabam.api.dto.room.RoutineResponse;
+import com.moabam.api.dto.room.TodayCertificateRankResponse;
 import com.moabam.global.common.util.ClockHolder;
 import com.moabam.global.common.util.UrlSubstringParser;
 import com.moabam.global.error.exception.BadRequestException;
@@ -414,7 +416,7 @@ private void saveNewCertifications(List imageUrls, Long memberId) {
 	}
 
 	private int getRoomLevelAfterExpApply(int roomLevel, Room room) {
-		int requireExp = RequireExp.of(roomLevel).getTotalExp();
+		int requireExp = RoomExp.of(roomLevel).getTotalExp();
 		room.gainExp();
 
 		if (room.getExp() == requireExp) {
diff --git a/src/main/java/com/moabam/api/dto/CertificationsMapper.java b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java
similarity index 82%
rename from src/main/java/com/moabam/api/dto/CertificationsMapper.java
rename to src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java
index bbaf37f4..9e612284 100644
--- a/src/main/java/com/moabam/api/dto/CertificationsMapper.java
+++ b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java
@@ -1,15 +1,17 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.room.mapper;
 
 import java.time.LocalDate;
 import java.util.ArrayList;
 import java.util.List;
 
-import com.moabam.api.domain.entity.Certification;
-import com.moabam.api.domain.entity.DailyMemberCertification;
-import com.moabam.api.domain.entity.DailyRoomCertification;
-import com.moabam.api.domain.entity.Member;
-import com.moabam.api.domain.entity.Participant;
-import com.moabam.api.domain.entity.Routine;
+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.Routine;
+import com.moabam.api.dto.room.CertificationImageResponse;
+import com.moabam.api.dto.room.TodayCertificateRankResponse;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/dto/RoomMapper.java b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java
similarity index 82%
rename from src/main/java/com/moabam/api/dto/RoomMapper.java
rename to src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java
index 0a97ae0c..5b005f0e 100644
--- a/src/main/java/com/moabam/api/dto/RoomMapper.java
+++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java
@@ -1,9 +1,13 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.room.mapper;
 
 import java.time.LocalDate;
 import java.util.List;
 
-import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.room.Room;
+import com.moabam.api.dto.room.CreateRoomRequest;
+import com.moabam.api.dto.room.RoomDetailsResponse;
+import com.moabam.api.dto.room.RoutineResponse;
+import com.moabam.api.dto.room.TodayCertificateRankResponse;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/dto/RoutineMapper.java b/src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java
similarity index 78%
rename from src/main/java/com/moabam/api/dto/RoutineMapper.java
rename to src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java
index 40e941a4..b2c362c6 100644
--- a/src/main/java/com/moabam/api/dto/RoutineMapper.java
+++ b/src/main/java/com/moabam/api/application/room/mapper/RoutineMapper.java
@@ -1,9 +1,10 @@
-package com.moabam.api.dto;
+package com.moabam.api.application.room.mapper;
 
 import java.util.List;
 
-import com.moabam.api.domain.entity.Room;
-import com.moabam.api.domain.entity.Routine;
+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;
diff --git a/src/main/java/com/moabam/api/domain/entity/Bug.java b/src/main/java/com/moabam/api/domain/bug/Bug.java
similarity index 95%
rename from src/main/java/com/moabam/api/domain/entity/Bug.java
rename to src/main/java/com/moabam/api/domain/bug/Bug.java
index 255febe7..246844cf 100644
--- a/src/main/java/com/moabam/api/domain/entity/Bug.java
+++ b/src/main/java/com/moabam/api/domain/bug/Bug.java
@@ -1,10 +1,9 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.bug;
 
 import static com.moabam.global.error.model.ErrorMessage.*;
 
 import org.hibernate.annotations.ColumnDefault;
 
-import com.moabam.api.domain.entity.enums.BugType;
 import com.moabam.global.error.exception.BadRequestException;
 
 import jakarta.persistence.Column;
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java b/src/main/java/com/moabam/api/domain/bug/BugActionType.java
similarity index 62%
rename from src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java
rename to src/main/java/com/moabam/api/domain/bug/BugActionType.java
index 3b9018b0..24dcba45 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/BugActionType.java
+++ b/src/main/java/com/moabam/api/domain/bug/BugActionType.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.bug;
 
 public enum BugActionType {
 
diff --git a/src/main/java/com/moabam/api/domain/entity/BugHistory.java b/src/main/java/com/moabam/api/domain/bug/BugHistory.java
similarity index 92%
rename from src/main/java/com/moabam/api/domain/entity/BugHistory.java
rename to src/main/java/com/moabam/api/domain/bug/BugHistory.java
index 5c620e9f..072a16ec 100644
--- a/src/main/java/com/moabam/api/domain/entity/BugHistory.java
+++ b/src/main/java/com/moabam/api/domain/bug/BugHistory.java
@@ -1,10 +1,8 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.bug;
 
 import static com.moabam.global.error.model.ErrorMessage.*;
 import static java.util.Objects.*;
 
-import com.moabam.api.domain.entity.enums.BugActionType;
-import com.moabam.api.domain.entity.enums.BugType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.error.exception.BadRequestException;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/BugType.java b/src/main/java/com/moabam/api/domain/bug/BugType.java
similarity index 72%
rename from src/main/java/com/moabam/api/domain/entity/enums/BugType.java
rename to src/main/java/com/moabam/api/domain/bug/BugType.java
index d7f3596a..9aa0535b 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/BugType.java
+++ b/src/main/java/com/moabam/api/domain/bug/BugType.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.bug;
 
 public enum BugType {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java b/src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java
rename to src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java
index 09c218c0..191d486e 100644
--- a/src/main/java/com/moabam/api/domain/repository/BugHistoryRepository.java
+++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistoryRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.bug.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.BugHistory;
+import com.moabam.api.domain.bug.BugHistory;
 
 public interface BugHistoryRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/entity/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java
similarity index 96%
rename from src/main/java/com/moabam/api/domain/entity/Coupon.java
rename to src/main/java/com/moabam/api/domain/coupon/Coupon.java
index 0b6c8ae9..1440069b 100644
--- a/src/main/java/com/moabam/api/domain/entity/Coupon.java
+++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.coupon;
 
 import static com.moabam.global.common.util.GlobalConstant.*;
 import static com.moabam.global.error.model.ErrorMessage.*;
@@ -9,7 +9,6 @@
 
 import org.hibernate.annotations.ColumnDefault;
 
-import com.moabam.api.domain.entity.enums.CouponType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.error.exception.BadRequestException;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/CouponType.java b/src/main/java/com/moabam/api/domain/coupon/CouponType.java
similarity index 95%
rename from src/main/java/com/moabam/api/domain/entity/enums/CouponType.java
rename to src/main/java/com/moabam/api/domain/coupon/CouponType.java
index 9cdb78de..f8bfa263 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/CouponType.java
+++ b/src/main/java/com/moabam/api/domain/coupon/CouponType.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.coupon;
 
 import java.util.Arrays;
 import java.util.Collections;
diff --git a/src/main/java/com/moabam/api/domain/repository/CouponRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java
similarity index 65%
rename from src/main/java/com/moabam/api/domain/repository/CouponRepository.java
rename to src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java
index 1a262050..b82795bc 100644
--- a/src/main/java/com/moabam/api/domain/repository/CouponRepository.java
+++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.coupon.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Coupon;
+import com.moabam.api.domain.coupon.Coupon;
 
 public interface CouponRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java
similarity index 91%
rename from src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java
rename to src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java
index 50631751..6687f343 100644
--- a/src/main/java/com/moabam/api/domain/repository/CouponSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java
@@ -1,6 +1,6 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.coupon.repository;
 
-import static com.moabam.api.domain.entity.QCoupon.*;
+import static com.moabam.api.domain.coupon.QCoupon.*;
 
 import java.time.LocalDateTime;
 import java.util.List;
@@ -8,8 +8,8 @@
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Coupon;
-import com.moabam.api.dto.CouponSearchRequest;
+import com.moabam.api.domain.coupon.Coupon;
+import com.moabam.api.dto.coupon.CouponSearchRequest;
 import com.querydsl.core.types.dsl.BooleanExpression;
 import com.querydsl.core.types.dsl.Expressions;
 import com.querydsl.jpa.impl.JPAQueryFactory;
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java b/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java
deleted file mode 100644
index 581698c0..00000000
--- a/src/main/java/com/moabam/api/domain/entity/enums/ItemCategory.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.moabam.api.domain.entity.enums;
-
-public enum ItemCategory {
-
-	SKIN;
-}
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/ProductType.java b/src/main/java/com/moabam/api/domain/entity/enums/ProductType.java
deleted file mode 100644
index 381b00a1..00000000
--- a/src/main/java/com/moabam/api/domain/entity/enums/ProductType.java
+++ /dev/null
@@ -1,6 +0,0 @@
-package com.moabam.api.domain.entity.enums;
-
-public enum ProductType {
-
-	BUG;
-}
diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java b/src/main/java/com/moabam/api/domain/image/ImageName.java
similarity index 95%
rename from src/main/java/com/moabam/api/domain/resizedimage/ImageName.java
rename to src/main/java/com/moabam/api/domain/image/ImageName.java
index 45ee6d4a..64082d54 100644
--- a/src/main/java/com/moabam/api/domain/resizedimage/ImageName.java
+++ b/src/main/java/com/moabam/api/domain/image/ImageName.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.resizedimage;
+package com.moabam.api.domain.image;
 
 import static com.moabam.global.common.util.GlobalConstant.*;
 
diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java b/src/main/java/com/moabam/api/domain/image/ImageResizer.java
similarity index 98%
rename from src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java
rename to src/main/java/com/moabam/api/domain/image/ImageResizer.java
index 409aa4e5..79e44a9e 100644
--- a/src/main/java/com/moabam/api/domain/resizedimage/ImageResizer.java
+++ b/src/main/java/com/moabam/api/domain/image/ImageResizer.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.resizedimage;
+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;
diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java b/src/main/java/com/moabam/api/domain/image/ImageSize.java
similarity index 84%
rename from src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java
rename to src/main/java/com/moabam/api/domain/image/ImageSize.java
index c6c5ed06..9f311251 100644
--- a/src/main/java/com/moabam/api/domain/resizedimage/ImageSize.java
+++ b/src/main/java/com/moabam/api/domain/image/ImageSize.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.resizedimage;
+package com.moabam.api.domain.image;
 
 import lombok.Getter;
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java b/src/main/java/com/moabam/api/domain/image/ImageType.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/resizedimage/ImageType.java
rename to src/main/java/com/moabam/api/domain/image/ImageType.java
index cf9936e5..80cbeba3 100644
--- a/src/main/java/com/moabam/api/domain/resizedimage/ImageType.java
+++ b/src/main/java/com/moabam/api/domain/image/ImageType.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.resizedimage;
+package com.moabam.api.domain.image;
 
 public enum ImageType {
 
diff --git a/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java b/src/main/java/com/moabam/api/domain/image/ResizedImage.java
similarity index 96%
rename from src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java
rename to src/main/java/com/moabam/api/domain/image/ResizedImage.java
index 6920382c..d7568527 100644
--- a/src/main/java/com/moabam/api/domain/resizedimage/ResizedImage.java
+++ b/src/main/java/com/moabam/api/domain/image/ResizedImage.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.resizedimage;
+package com.moabam.api.domain.image;
 
 import java.io.ByteArrayInputStream;
 import java.io.File;
diff --git a/src/main/java/com/moabam/api/domain/entity/Inventory.java b/src/main/java/com/moabam/api/domain/item/Inventory.java
similarity index 94%
rename from src/main/java/com/moabam/api/domain/entity/Inventory.java
rename to src/main/java/com/moabam/api/domain/item/Inventory.java
index dd493564..c17d507b 100644
--- a/src/main/java/com/moabam/api/domain/entity/Inventory.java
+++ b/src/main/java/com/moabam/api/domain/item/Inventory.java
@@ -1,10 +1,9 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.item;
 
 import static java.util.Objects.*;
 
 import org.hibernate.annotations.ColumnDefault;
 
-import com.moabam.api.domain.entity.enums.ItemType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 
 import jakarta.persistence.Column;
diff --git a/src/main/java/com/moabam/api/domain/entity/Item.java b/src/main/java/com/moabam/api/domain/item/Item.java
similarity index 93%
rename from src/main/java/com/moabam/api/domain/entity/Item.java
rename to src/main/java/com/moabam/api/domain/item/Item.java
index ebe7512f..ad7bbd0d 100644
--- a/src/main/java/com/moabam/api/domain/entity/Item.java
+++ b/src/main/java/com/moabam/api/domain/item/Item.java
@@ -1,13 +1,11 @@
-package com.moabam.api.domain.entity;
+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.entity.enums.BugType;
-import com.moabam.api.domain.entity.enums.ItemCategory;
-import com.moabam.api.domain.entity.enums.ItemType;
+import com.moabam.api.domain.bug.BugType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.error.exception.BadRequestException;
 
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/entity/enums/ItemType.java b/src/main/java/com/moabam/api/domain/item/ItemType.java
similarity index 83%
rename from src/main/java/com/moabam/api/domain/entity/enums/ItemType.java
rename to src/main/java/com/moabam/api/domain/item/ItemType.java
index e2baefe4..4297d567 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/ItemType.java
+++ b/src/main/java/com/moabam/api/domain/item/ItemType.java
@@ -1,7 +1,9 @@
-package com.moabam.api.domain.entity.enums;
+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)),
diff --git a/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/repository/InventoryRepository.java
rename to src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java
index bac07502..73a044f3 100644
--- a/src/main/java/com/moabam/api/domain/repository/InventoryRepository.java
+++ b/src/main/java/com/moabam/api/domain/item/repository/InventoryRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.item.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Inventory;
+import com.moabam.api.domain.item.Inventory;
 
 public interface InventoryRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java
similarity index 82%
rename from src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java
rename to src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java
index 44a17bda..2ab6e605 100644
--- a/src/main/java/com/moabam/api/domain/repository/InventorySearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java
@@ -1,16 +1,16 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.item.repository;
 
-import static com.moabam.api.domain.entity.QInventory.*;
-import static com.moabam.api.domain.entity.QItem.*;
+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.entity.Inventory;
-import com.moabam.api.domain.entity.Item;
-import com.moabam.api.domain.entity.enums.ItemType;
+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;
 
diff --git a/src/main/java/com/moabam/api/domain/repository/ItemRepository.java b/src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java
similarity index 60%
rename from src/main/java/com/moabam/api/domain/repository/ItemRepository.java
rename to src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java
index ae5eede0..dd5554b8 100644
--- a/src/main/java/com/moabam/api/domain/repository/ItemRepository.java
+++ b/src/main/java/com/moabam/api/domain/item/repository/ItemRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.item.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Item;
+import com.moabam.api.domain.item.Item;
 
 public interface ItemRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java
similarity index 79%
rename from src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java
rename to src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java
index 8f799973..f2b0f150 100644
--- a/src/main/java/com/moabam/api/domain/repository/ItemSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java
@@ -1,14 +1,14 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.item.repository;
 
-import static com.moabam.api.domain.entity.QInventory.*;
-import static com.moabam.api.domain.entity.QItem.*;
+import static com.moabam.api.domain.item.QInventory.*;
+import static com.moabam.api.domain.item.QItem.*;
 
 import java.util.List;
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Item;
-import com.moabam.api.domain.entity.enums.ItemType;
+import com.moabam.api.domain.item.Item;
+import com.moabam.api.domain.item.ItemType;
 import com.moabam.global.common.util.DynamicQuery;
 import com.querydsl.core.types.dsl.BooleanExpression;
 import com.querydsl.jpa.impl.JPAQueryFactory;
diff --git a/src/main/java/com/moabam/api/domain/entity/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java
similarity index 97%
rename from src/main/java/com/moabam/api/domain/entity/Member.java
rename to src/main/java/com/moabam/api/domain/member/Member.java
index bee72c09..c6988063 100644
--- a/src/main/java/com/moabam/api/domain/entity/Member.java
+++ b/src/main/java/com/moabam/api/domain/member/Member.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.member;
 
 import static com.moabam.global.common.util.GlobalConstant.*;
 import static java.util.Objects.*;
@@ -9,7 +9,7 @@
 import org.hibernate.annotations.SQLDelete;
 import org.hibernate.annotations.Where;
 
-import com.moabam.api.domain.entity.enums.Role;
+import com.moabam.api.domain.bug.Bug;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.common.util.BaseImageUrl;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/Role.java b/src/main/java/com/moabam/api/domain/member/Role.java
similarity index 50%
rename from src/main/java/com/moabam/api/domain/entity/enums/Role.java
rename to src/main/java/com/moabam/api/domain/member/Role.java
index b90adc44..b7e80810 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/Role.java
+++ b/src/main/java/com/moabam/api/domain/member/Role.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.member;
 
 public enum Role {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java b/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java
similarity index 69%
rename from src/main/java/com/moabam/api/domain/repository/MemberRepository.java
rename to src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java
index 94bd696b..dfa82196 100644
--- a/src/main/java/com/moabam/api/domain/repository/MemberRepository.java
+++ b/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java
@@ -1,10 +1,10 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.member.repository;
 
 import java.util.Optional;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Member;
+import com.moabam.api.domain.member.Member;
 
 public interface MemberRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java
similarity index 74%
rename from src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java
rename to src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java
index 9970db48..47ff7ab8 100644
--- a/src/main/java/com/moabam/api/domain/repository/MemberSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java
@@ -1,13 +1,13 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.member.repository;
 
-import static com.moabam.api.domain.entity.QMember.*;
-import static com.moabam.api.domain.entity.QParticipant.*;
+import static com.moabam.api.domain.member.QMember.*;
+import static com.moabam.api.domain.room.QParticipant.*;
 
 import java.util.Optional;
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Member;
+import com.moabam.api.domain.member.Member;
 import com.querydsl.jpa.impl.JPAQueryFactory;
 
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/domain/entity/Product.java b/src/main/java/com/moabam/api/domain/product/Product.java
similarity index 95%
rename from src/main/java/com/moabam/api/domain/entity/Product.java
rename to src/main/java/com/moabam/api/domain/product/Product.java
index fad1b7d4..d99249e1 100644
--- a/src/main/java/com/moabam/api/domain/entity/Product.java
+++ b/src/main/java/com/moabam/api/domain/product/Product.java
@@ -1,11 +1,10 @@
-package com.moabam.api.domain.entity;
+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.api.domain.entity.enums.ProductType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.error.exception.BadRequestException;
 
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/repository/ProductRepository.java b/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java
similarity index 59%
rename from src/main/java/com/moabam/api/domain/repository/ProductRepository.java
rename to src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java
index 17c91398..4156d9a9 100644
--- a/src/main/java/com/moabam/api/domain/repository/ProductRepository.java
+++ b/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.product.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Product;
+import com.moabam.api.domain.product.Product;
 
 public interface ProductRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/entity/Certification.java b/src/main/java/com/moabam/api/domain/room/Certification.java
similarity index 97%
rename from src/main/java/com/moabam/api/domain/entity/Certification.java
rename to src/main/java/com/moabam/api/domain/room/Certification.java
index eeff890e..c2ce121b 100644
--- a/src/main/java/com/moabam/api/domain/entity/Certification.java
+++ b/src/main/java/com/moabam/api/domain/room/Certification.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java b/src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java
similarity index 97%
rename from src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java
rename to src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java
index 1b9f6d19..afe64cd2 100644
--- a/src/main/java/com/moabam/api/domain/entity/DailyMemberCertification.java
+++ b/src/main/java/com/moabam/api/domain/room/DailyMemberCertification.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java b/src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java
similarity index 96%
rename from src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java
rename to src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java
index 2e345b8a..573261ba 100644
--- a/src/main/java/com/moabam/api/domain/entity/DailyRoomCertification.java
+++ b/src/main/java/com/moabam/api/domain/room/DailyRoomCertification.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/Participant.java b/src/main/java/com/moabam/api/domain/room/Participant.java
similarity index 98%
rename from src/main/java/com/moabam/api/domain/entity/Participant.java
rename to src/main/java/com/moabam/api/domain/room/Participant.java
index 09762b85..d6274a4d 100644
--- a/src/main/java/com/moabam/api/domain/entity/Participant.java
+++ b/src/main/java/com/moabam/api/domain/room/Participant.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java
similarity index 96%
rename from src/main/java/com/moabam/api/domain/entity/Room.java
rename to src/main/java/com/moabam/api/domain/room/Room.java
index 10a9c13c..4608ced6 100644
--- a/src/main/java/com/moabam/api/domain/entity/Room.java
+++ b/src/main/java/com/moabam/api/domain/room/Room.java
@@ -1,12 +1,11 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
-import static com.moabam.api.domain.entity.enums.RoomType.*;
+import static com.moabam.api.domain.room.RoomType.*;
 import static com.moabam.global.error.model.ErrorMessage.*;
 import static java.util.Objects.*;
 
 import org.hibernate.annotations.ColumnDefault;
 
-import com.moabam.api.domain.entity.enums.RoomType;
 import com.moabam.global.common.entity.BaseTimeEntity;
 import com.moabam.global.error.exception.BadRequestException;
 
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java b/src/main/java/com/moabam/api/domain/room/RoomExp.java
similarity index 75%
rename from src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java
rename to src/main/java/com/moabam/api/domain/room/RoomExp.java
index 96233fd9..409b91fb 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/RequireExp.java
+++ b/src/main/java/com/moabam/api/domain/room/RoomExp.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.room;
 
 import java.util.Collections;
 import java.util.Map;
@@ -20,7 +20,7 @@
 
 @Getter
 @RequiredArgsConstructor(access = AccessLevel.PRIVATE)
-public enum RequireExp {
+public enum RoomExp {
 
 	ROOM_LEVEL_0(0, 1),
 	ROOM_LEVEL_1(1, 5),
@@ -31,13 +31,13 @@ public enum RequireExp {
 
 	private static final Map requireExpMap = Collections.unmodifiableMap(
 		Stream.of(values())
-			.collect(Collectors.toMap(RequireExp::getLevel, RequireExp::name))
+			.collect(Collectors.toMap(RoomExp::getLevel, RoomExp::name))
 	);
 
 	private final int level;
 	private final int totalExp;
 
-	public static RequireExp of(int level) {
-		return RequireExp.valueOf(requireExpMap.get(level));
+	public static RoomExp of(int level) {
+		return RoomExp.valueOf(requireExpMap.get(level));
 	}
 }
diff --git a/src/main/java/com/moabam/api/domain/entity/enums/RoomType.java b/src/main/java/com/moabam/api/domain/room/RoomType.java
similarity index 50%
rename from src/main/java/com/moabam/api/domain/entity/enums/RoomType.java
rename to src/main/java/com/moabam/api/domain/room/RoomType.java
index e9f8342a..fd63618b 100644
--- a/src/main/java/com/moabam/api/domain/entity/enums/RoomType.java
+++ b/src/main/java/com/moabam/api/domain/room/RoomType.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity.enums;
+package com.moabam.api.domain.room;
 
 public enum RoomType {
 
diff --git a/src/main/java/com/moabam/api/domain/entity/Routine.java b/src/main/java/com/moabam/api/domain/room/Routine.java
similarity index 97%
rename from src/main/java/com/moabam/api/domain/entity/Routine.java
rename to src/main/java/com/moabam/api/domain/room/Routine.java
index e370388f..6b3f0a86 100644
--- a/src/main/java/com/moabam/api/domain/entity/Routine.java
+++ b/src/main/java/com/moabam/api/domain/room/Routine.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.entity;
+package com.moabam.api.domain.room;
 
 import static com.moabam.global.error.model.ErrorMessage.*;
 import static java.util.Objects.*;
diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/repository/CertificationRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java
index 4d794056..c8389591 100644
--- a/src/main/java/com/moabam/api/domain/repository/CertificationRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Certification;
+import com.moabam.api.domain.room.Certification;
 
 public interface CertificationRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java
similarity index 82%
rename from src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java
index f8c5488c..587037b9 100644
--- a/src/main/java/com/moabam/api/domain/repository/CertificationsSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java
@@ -1,9 +1,9 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
-import static com.moabam.api.domain.entity.QCertification.*;
-import static com.moabam.api.domain.entity.QDailyMemberCertification.*;
-import static com.moabam.api.domain.entity.QDailyRoomCertification.*;
-import static com.moabam.api.domain.entity.QParticipant.*;
+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;
@@ -12,9 +12,9 @@
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Certification;
-import com.moabam.api.domain.entity.DailyMemberCertification;
-import com.moabam.api.domain.entity.DailyRoomCertification;
+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 lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java
similarity index 62%
rename from src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java
index d5c98951..2d3f7fa2 100644
--- a/src/main/java/com/moabam/api/domain/repository/DailyMemberCertificationRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.DailyMemberCertification;
+import com.moabam.api.domain.room.DailyMemberCertification;
 
 public interface DailyMemberCertificationRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java b/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java
similarity index 62%
rename from src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java
index baf84d7e..16097e8d 100644
--- a/src/main/java/com/moabam/api/domain/repository/DailyRoomCertificationRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.DailyRoomCertification;
+import com.moabam.api.domain.room.DailyRoomCertification;
 
 public interface DailyRoomCertificationRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java b/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java
index 3f0a7bfc..6d79e3df 100644
--- a/src/main/java/com/moabam/api/domain/repository/ParticipantRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Participant;
+import com.moabam.api.domain.room.Participant;
 
 public interface ParticipantRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java
similarity index 87%
rename from src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java
index bff41c2d..25ef10dc 100644
--- a/src/main/java/com/moabam/api/domain/repository/ParticipantSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java
@@ -1,14 +1,14 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
-import static com.moabam.api.domain.entity.QParticipant.*;
-import static com.moabam.api.domain.entity.QRoom.*;
+import static com.moabam.api.domain.room.QParticipant.*;
+import static com.moabam.api.domain.room.QRoom.*;
 
 import java.util.List;
 import java.util.Optional;
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Participant;
+import com.moabam.api.domain.room.Participant;
 import com.moabam.global.common.util.DynamicQuery;
 import com.querydsl.jpa.impl.JPAQueryFactory;
 
diff --git a/src/main/java/com/moabam/api/domain/repository/RoomRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java
similarity index 60%
rename from src/main/java/com/moabam/api/domain/repository/RoomRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java
index 96c8d99f..4d667a71 100644
--- a/src/main/java/com/moabam/api/domain/repository/RoomRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Room;
+import com.moabam.api.domain.room.Room;
 
 public interface RoomRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/RoutineRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java
similarity index 61%
rename from src/main/java/com/moabam/api/domain/repository/RoutineRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java
index 099e82da..d4f0e1b9 100644
--- a/src/main/java/com/moabam/api/domain/repository/RoutineRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java
@@ -1,8 +1,8 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
 import org.springframework.data.jpa.repository.JpaRepository;
 
-import com.moabam.api.domain.entity.Routine;
+import com.moabam.api.domain.room.Routine;
 
 public interface RoutineRepository extends JpaRepository {
 
diff --git a/src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java
similarity index 76%
rename from src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java
rename to src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java
index ee18d348..f2f11bb6 100644
--- a/src/main/java/com/moabam/api/domain/repository/RoutineSearchRepository.java
+++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java
@@ -1,12 +1,12 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.domain.room.repository;
 
-import static com.moabam.api.domain.entity.QRoutine.*;
+import static com.moabam.api.domain.room.QRoutine.*;
 
 import java.util.List;
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.api.domain.entity.Routine;
+import com.moabam.api.domain.room.Routine;
 import com.querydsl.jpa.impl.JPAQueryFactory;
 
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java
similarity index 94%
rename from src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java
rename to src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java
index b6c30db5..cadd3452 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationCodeRequest.java
+++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java
similarity index 78%
rename from src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java
rename to src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java
index da8c19c8..5b652696 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationCodeResponse.java
+++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationCodeResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 public record AuthorizationCodeResponse(
 	String code,
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java
similarity index 87%
rename from src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java
rename to src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java
index 9268516d..8f7c5ff6 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationTokenInfoResponse.java
+++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenInfoResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java
similarity index 94%
rename from src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java
rename to src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java
index 17468c76..6254c13e 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationTokenRequest.java
+++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 import static java.util.Objects.*;
 
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java
similarity index 93%
rename from src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java
rename to src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java
index 80a0d5c6..04609d09 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationTokenResponse.java
+++ b/src/main/java/com/moabam/api/dto/auth/AuthorizationTokenResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
diff --git a/src/main/java/com/moabam/api/dto/LoginResponse.java b/src/main/java/com/moabam/api/dto/auth/LoginResponse.java
similarity index 69%
rename from src/main/java/com/moabam/api/dto/LoginResponse.java
rename to src/main/java/com/moabam/api/dto/auth/LoginResponse.java
index 4d22458e..8c75e8b4 100644
--- a/src/main/java/com/moabam/api/dto/LoginResponse.java
+++ b/src/main/java/com/moabam/api/dto/auth/LoginResponse.java
@@ -1,6 +1,7 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.auth;
 
 import com.fasterxml.jackson.annotation.JsonUnwrapped;
+import com.moabam.global.auth.model.PublicClaim;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/BugResponse.java b/src/main/java/com/moabam/api/dto/bug/BugResponse.java
similarity index 78%
rename from src/main/java/com/moabam/api/dto/BugResponse.java
rename to src/main/java/com/moabam/api/dto/bug/BugResponse.java
index 256c18cb..9493a76c 100644
--- a/src/main/java/com/moabam/api/dto/BugResponse.java
+++ b/src/main/java/com/moabam/api/dto/bug/BugResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.bug;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java
similarity index 83%
rename from src/main/java/com/moabam/api/dto/CouponResponse.java
rename to src/main/java/com/moabam/api/dto/coupon/CouponResponse.java
index bdf24ad6..3be5e025 100644
--- a/src/main/java/com/moabam/api/dto/CouponResponse.java
+++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java
@@ -1,9 +1,9 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.coupon;
 
 import java.time.LocalDateTime;
 
 import com.fasterxml.jackson.annotation.JsonFormat;
-import com.moabam.api.domain.entity.enums.CouponType;
+import com.moabam.api.domain.coupon.CouponType;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/CouponSearchRequest.java b/src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java
similarity index 80%
rename from src/main/java/com/moabam/api/dto/CouponSearchRequest.java
rename to src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java
index 2863aef0..8e8e1c2d 100644
--- a/src/main/java/com/moabam/api/dto/CouponSearchRequest.java
+++ b/src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.coupon;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/CreateCouponRequest.java b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java
similarity index 97%
rename from src/main/java/com/moabam/api/dto/CreateCouponRequest.java
rename to src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java
index 6b414bcf..ade47498 100644
--- a/src/main/java/com/moabam/api/dto/CreateCouponRequest.java
+++ b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.coupon;
 
 import java.time.LocalDateTime;
 
diff --git a/src/main/java/com/moabam/api/dto/ItemResponse.java b/src/main/java/com/moabam/api/dto/item/ItemResponse.java
similarity index 85%
rename from src/main/java/com/moabam/api/dto/ItemResponse.java
rename to src/main/java/com/moabam/api/dto/item/ItemResponse.java
index 13c83ace..1ae4ae79 100644
--- a/src/main/java/com/moabam/api/dto/ItemResponse.java
+++ b/src/main/java/com/moabam/api/dto/item/ItemResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.item;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/ItemsResponse.java b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java
similarity index 83%
rename from src/main/java/com/moabam/api/dto/ItemsResponse.java
rename to src/main/java/com/moabam/api/dto/item/ItemsResponse.java
index a0d323c8..085d339d 100644
--- a/src/main/java/com/moabam/api/dto/ItemsResponse.java
+++ b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.item;
 
 import java.util.List;
 
diff --git a/src/main/java/com/moabam/api/dto/PurchaseItemRequest.java b/src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java
similarity index 59%
rename from src/main/java/com/moabam/api/dto/PurchaseItemRequest.java
rename to src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java
index ca731ae6..0df65e1f 100644
--- a/src/main/java/com/moabam/api/dto/PurchaseItemRequest.java
+++ b/src/main/java/com/moabam/api/dto/item/PurchaseItemRequest.java
@@ -1,6 +1,6 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.item;
 
-import com.moabam.api.domain.entity.enums.BugType;
+import com.moabam.api.domain.bug.BugType;
 
 import jakarta.validation.constraints.NotNull;
 
diff --git a/src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java b/src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java
similarity index 80%
rename from src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java
rename to src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java
index fe500e5c..6c0044dc 100644
--- a/src/main/java/com/moabam/api/dto/KnockNotificationStatusResponse.java
+++ b/src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.notification;
 
 import java.util.List;
 
diff --git a/src/main/java/com/moabam/api/dto/ProductResponse.java b/src/main/java/com/moabam/api/dto/product/ProductResponse.java
similarity index 79%
rename from src/main/java/com/moabam/api/dto/ProductResponse.java
rename to src/main/java/com/moabam/api/dto/product/ProductResponse.java
index 547f7396..bd18b595 100644
--- a/src/main/java/com/moabam/api/dto/ProductResponse.java
+++ b/src/main/java/com/moabam/api/dto/product/ProductResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.product;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/ProductsResponse.java b/src/main/java/com/moabam/api/dto/product/ProductsResponse.java
similarity index 78%
rename from src/main/java/com/moabam/api/dto/ProductsResponse.java
rename to src/main/java/com/moabam/api/dto/product/ProductsResponse.java
index 6f69956c..21b99059 100644
--- a/src/main/java/com/moabam/api/dto/ProductsResponse.java
+++ b/src/main/java/com/moabam/api/dto/product/ProductsResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.product;
 
 import java.util.List;
 
diff --git a/src/main/java/com/moabam/api/dto/CertificationImageResponse.java b/src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java
similarity index 77%
rename from src/main/java/com/moabam/api/dto/CertificationImageResponse.java
rename to src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java
index 33d96acb..110d6798 100644
--- a/src/main/java/com/moabam/api/dto/CertificationImageResponse.java
+++ b/src/main/java/com/moabam/api/dto/room/CertificationImageResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/CreateRoomRequest.java b/src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java
similarity index 88%
rename from src/main/java/com/moabam/api/dto/CreateRoomRequest.java
rename to src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java
index a239943f..a6eeffeb 100644
--- a/src/main/java/com/moabam/api/dto/CreateRoomRequest.java
+++ b/src/main/java/com/moabam/api/dto/room/CreateRoomRequest.java
@@ -1,11 +1,11 @@
-package com.moabam.api.dto;
+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.entity.enums.RoomType;
+import com.moabam.api.domain.room.RoomType;
 
 import jakarta.validation.constraints.NotBlank;
 import jakarta.validation.constraints.NotNull;
diff --git a/src/main/java/com/moabam/api/dto/EnterRoomRequest.java b/src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java
similarity index 81%
rename from src/main/java/com/moabam/api/dto/EnterRoomRequest.java
rename to src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java
index 25da6d06..fc3d511b 100644
--- a/src/main/java/com/moabam/api/dto/EnterRoomRequest.java
+++ b/src/main/java/com/moabam/api/dto/room/EnterRoomRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import jakarta.validation.constraints.Pattern;
 
diff --git a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java
similarity index 95%
rename from src/main/java/com/moabam/api/dto/ModifyRoomRequest.java
rename to src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java
index ecef68bc..5d5fd558 100644
--- a/src/main/java/com/moabam/api/dto/ModifyRoomRequest.java
+++ b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import java.util.List;
 
diff --git a/src/main/java/com/moabam/api/dto/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java
similarity index 93%
rename from src/main/java/com/moabam/api/dto/RoomDetailsResponse.java
rename to src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java
index b90f8c70..0d870761 100644
--- a/src/main/java/com/moabam/api/dto/RoomDetailsResponse.java
+++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import java.time.LocalDate;
 import java.util.List;
diff --git a/src/main/java/com/moabam/api/dto/RoutineResponse.java b/src/main/java/com/moabam/api/dto/room/RoutineResponse.java
similarity index 76%
rename from src/main/java/com/moabam/api/dto/RoutineResponse.java
rename to src/main/java/com/moabam/api/dto/room/RoutineResponse.java
index 8f02990b..37ae2c16 100644
--- a/src/main/java/com/moabam/api/dto/RoutineResponse.java
+++ b/src/main/java/com/moabam/api/dto/room/RoutineResponse.java
@@ -1,4 +1,4 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java
similarity index 75%
rename from src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java
rename to src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java
index 970f6877..832f4fa8 100644
--- a/src/main/java/com/moabam/api/dto/TodayCertificateRankResponse.java
+++ b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java
@@ -1,7 +1,9 @@
-package com.moabam.api.dto;
+package com.moabam.api.dto.room;
 
 import java.util.List;
 
+import com.moabam.api.dto.room.CertificationImageResponse;
+
 import lombok.Builder;
 
 @Builder
diff --git a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java
similarity index 93%
rename from src/main/java/com/moabam/api/domain/repository/NotificationRepository.java
rename to src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java
index f93f9ac2..70b25df9 100644
--- a/src/main/java/com/moabam/api/domain/repository/NotificationRepository.java
+++ b/src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java
@@ -1,4 +1,4 @@
-package com.moabam.api.domain.repository;
+package com.moabam.api.infrastructure.redis;
 
 import static com.moabam.global.common.util.GlobalConstant.*;
 import static java.util.Objects.*;
@@ -7,8 +7,6 @@
 
 import org.springframework.stereotype.Repository;
 
-import com.moabam.global.common.repository.StringRedisRepository;
-
 import lombok.RequiredArgsConstructor;
 
 @Repository
diff --git a/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java
similarity index 94%
rename from src/main/java/com/moabam/global/common/repository/StringRedisRepository.java
rename to src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java
index e53ab515..9d877dae 100644
--- a/src/main/java/com/moabam/global/common/repository/StringRedisRepository.java
+++ b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java
@@ -1,4 +1,4 @@
-package com.moabam.global.common.repository;
+package com.moabam.api.infrastructure.redis;
 
 import java.time.Duration;
 
diff --git a/src/main/java/com/moabam/api/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java
index d17dfa32..12f9a841 100644
--- a/src/main/java/com/moabam/api/presentation/BugController.java
+++ b/src/main/java/com/moabam/api/presentation/BugController.java
@@ -6,8 +6,8 @@
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.BugService;
-import com.moabam.api.dto.BugResponse;
+import com.moabam.api.application.bug.BugService;
+import com.moabam.api.dto.bug.BugResponse;
 
 import lombok.RequiredArgsConstructor;
 
diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java
index 561fee2c..4a5e0d85 100644
--- a/src/main/java/com/moabam/api/presentation/CouponController.java
+++ b/src/main/java/com/moabam/api/presentation/CouponController.java
@@ -11,10 +11,10 @@
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.CouponService;
-import com.moabam.api.dto.CouponResponse;
-import com.moabam.api.dto.CouponSearchRequest;
-import com.moabam.api.dto.CreateCouponRequest;
+import com.moabam.api.application.coupon.CouponService;
+import com.moabam.api.dto.coupon.CouponResponse;
+import com.moabam.api.dto.coupon.CouponSearchRequest;
+import com.moabam.api.dto.coupon.CreateCouponRequest;
 
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java
index 64192fc2..5c6b47ff 100644
--- a/src/main/java/com/moabam/api/presentation/ItemController.java
+++ b/src/main/java/com/moabam/api/presentation/ItemController.java
@@ -10,10 +10,10 @@
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.ItemService;
-import com.moabam.api.domain.entity.enums.ItemType;
-import com.moabam.api.dto.ItemsResponse;
-import com.moabam.api.dto.PurchaseItemRequest;
+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 jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java
index f7a96591..e820777f 100644
--- a/src/main/java/com/moabam/api/presentation/MemberController.java
+++ b/src/main/java/com/moabam/api/presentation/MemberController.java
@@ -7,11 +7,11 @@
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.AuthorizationService;
-import com.moabam.api.dto.AuthorizationCodeResponse;
-import com.moabam.api.dto.AuthorizationTokenInfoResponse;
-import com.moabam.api.dto.AuthorizationTokenResponse;
-import com.moabam.api.dto.LoginResponse;
+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;
diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java
index 7121966a..a02a1061 100644
--- a/src/main/java/com/moabam/api/presentation/NotificationController.java
+++ b/src/main/java/com/moabam/api/presentation/NotificationController.java
@@ -5,8 +5,8 @@
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.NotificationService;
-import com.moabam.global.common.annotation.MemberTest;
+import com.moabam.api.application.notification.NotificationService;
+import com.moabam.global.auth.annotation.MemberTest;
 
 import lombok.RequiredArgsConstructor;
 
diff --git a/src/main/java/com/moabam/api/presentation/ProductController.java b/src/main/java/com/moabam/api/presentation/ProductController.java
index 7a686810..cad9cd79 100644
--- a/src/main/java/com/moabam/api/presentation/ProductController.java
+++ b/src/main/java/com/moabam/api/presentation/ProductController.java
@@ -6,8 +6,8 @@
 import org.springframework.web.bind.annotation.ResponseStatus;
 import org.springframework.web.bind.annotation.RestController;
 
-import com.moabam.api.application.ProductService;
-import com.moabam.api.dto.ProductsResponse;
+import com.moabam.api.application.product.ProductService;
+import com.moabam.api.dto.product.ProductsResponse;
 
 import lombok.RequiredArgsConstructor;
 
diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java
index a03d6d19..55545600 100644
--- a/src/main/java/com/moabam/api/presentation/RoomController.java
+++ b/src/main/java/com/moabam/api/presentation/RoomController.java
@@ -15,11 +15,11 @@
 import org.springframework.web.bind.annotation.RestController;
 import org.springframework.web.multipart.MultipartFile;
 
-import com.moabam.api.application.RoomService;
-import com.moabam.api.dto.CreateRoomRequest;
-import com.moabam.api.dto.EnterRoomRequest;
-import com.moabam.api.dto.ModifyRoomRequest;
-import com.moabam.api.dto.RoomDetailsResponse;
+import com.moabam.api.application.room.RoomService;
+import com.moabam.api.dto.room.CreateRoomRequest;
+import com.moabam.api.dto.room.EnterRoomRequest;
+import com.moabam.api.dto.room.ModifyRoomRequest;
+import com.moabam.api.dto.room.RoomDetailsResponse;
 
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
diff --git a/src/main/java/com/moabam/global/common/annotation/CurrentMember.java b/src/main/java/com/moabam/global/auth/annotation/CurrentMember.java
similarity index 85%
rename from src/main/java/com/moabam/global/common/annotation/CurrentMember.java
rename to src/main/java/com/moabam/global/auth/annotation/CurrentMember.java
index 2d094ced..58fdea29 100644
--- a/src/main/java/com/moabam/global/common/annotation/CurrentMember.java
+++ b/src/main/java/com/moabam/global/auth/annotation/CurrentMember.java
@@ -1,4 +1,4 @@
-package com.moabam.global.common.annotation;
+package com.moabam.global.auth.annotation;
 
 import java.lang.annotation.ElementType;
 import java.lang.annotation.Retention;
diff --git a/src/main/java/com/moabam/global/common/annotation/MemberTest.java b/src/main/java/com/moabam/global/auth/annotation/MemberTest.java
similarity index 59%
rename from src/main/java/com/moabam/global/common/annotation/MemberTest.java
rename to src/main/java/com/moabam/global/auth/annotation/MemberTest.java
index a1449cba..a426856d 100644
--- a/src/main/java/com/moabam/global/common/annotation/MemberTest.java
+++ b/src/main/java/com/moabam/global/auth/annotation/MemberTest.java
@@ -1,4 +1,4 @@
-package com.moabam.global.common.annotation;
+package com.moabam.global.auth.annotation;
 
 public record MemberTest(
 	Long memberId,
diff --git a/src/main/java/com/moabam/global/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
similarity index 91%
rename from src/main/java/com/moabam/global/filter/AuthorizationFilter.java
rename to src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
index 5aeea6fc..27368bd4 100644
--- a/src/main/java/com/moabam/global/filter/AuthorizationFilter.java
+++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
@@ -1,4 +1,4 @@
-package com.moabam.global.filter;
+package com.moabam.global.auth.filter;
 
 import java.io.IOException;
 import java.util.Arrays;
@@ -10,11 +10,11 @@
 import org.springframework.web.filter.OncePerRequestFilter;
 import org.springframework.web.servlet.HandlerExceptionResolver;
 
-import com.moabam.api.application.AuthorizationService;
-import com.moabam.api.application.JwtAuthenticationService;
-import com.moabam.api.dto.AuthorizationMapper;
-import com.moabam.api.dto.PublicClaim;
-import com.moabam.global.common.util.AuthorizationThreadLocal;
+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.global.auth.model.AuthorizationThreadLocal;
+import com.moabam.global.auth.model.PublicClaim;
 import com.moabam.global.error.exception.UnauthorizedException;
 import com.moabam.global.error.model.ErrorMessage;
 
diff --git a/src/main/java/com/moabam/global/filter/PathFilter.java b/src/main/java/com/moabam/global/auth/filter/PathFilter.java
similarity index 84%
rename from src/main/java/com/moabam/global/filter/PathFilter.java
rename to src/main/java/com/moabam/global/auth/filter/PathFilter.java
index 4baf8aa9..9d3c9bc1 100644
--- a/src/main/java/com/moabam/global/filter/PathFilter.java
+++ b/src/main/java/com/moabam/global/auth/filter/PathFilter.java
@@ -1,4 +1,4 @@
-package com.moabam.global.filter;
+package com.moabam.global.auth.filter;
 
 import java.io.IOException;
 import java.util.Optional;
@@ -7,7 +7,7 @@
 import org.springframework.stereotype.Component;
 import org.springframework.web.filter.OncePerRequestFilter;
 
-import com.moabam.global.common.handler.PathResolver;
+import com.moabam.global.auth.handler.PathResolver;
 
 import jakarta.servlet.FilterChain;
 import jakarta.servlet.ServletException;
@@ -23,7 +23,7 @@ public class PathFilter extends OncePerRequestFilter {
 	private final PathResolver pathResolver;
 
 	@Override
-	protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
+	public void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
 		FilterChain filterChain) throws ServletException, IOException {
 		Optional matchedPath = pathResolver.permitPathMatch(request.getRequestURI());
 
diff --git a/src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java b/src/main/java/com/moabam/global/auth/handler/CurrentMemberArgumentResolver.java
similarity index 80%
rename from src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java
rename to src/main/java/com/moabam/global/auth/handler/CurrentMemberArgumentResolver.java
index 1d0d9760..43aafba1 100644
--- a/src/main/java/com/moabam/global/common/handler/CurrentMemberArgumentResolver.java
+++ b/src/main/java/com/moabam/global/auth/handler/CurrentMemberArgumentResolver.java
@@ -1,6 +1,6 @@
-package com.moabam.global.common.handler;
+package com.moabam.global.auth.handler;
 
-import static com.moabam.global.common.util.AuthorizationThreadLocal.*;
+import static com.moabam.global.auth.model.AuthorizationThreadLocal.*;
 
 import java.util.Objects;
 
@@ -12,8 +12,8 @@
 import org.springframework.web.method.support.HandlerMethodArgumentResolver;
 import org.springframework.web.method.support.ModelAndViewContainer;
 
-import com.moabam.api.dto.AuthorizationMember;
-import com.moabam.global.common.annotation.CurrentMember;
+import com.moabam.global.auth.annotation.CurrentMember;
+import com.moabam.global.auth.model.AuthorizationMember;
 
 public class CurrentMemberArgumentResolver implements HandlerMethodArgumentResolver {
 
diff --git a/src/main/java/com/moabam/global/common/handler/PathResolver.java b/src/main/java/com/moabam/global/auth/handler/PathResolver.java
similarity index 96%
rename from src/main/java/com/moabam/global/common/handler/PathResolver.java
rename to src/main/java/com/moabam/global/auth/handler/PathResolver.java
index 76ab8cb4..46d2c32d 100644
--- a/src/main/java/com/moabam/global/common/handler/PathResolver.java
+++ b/src/main/java/com/moabam/global/auth/handler/PathResolver.java
@@ -1,4 +1,4 @@
-package com.moabam.global.common.handler;
+package com.moabam.global.auth.handler;
 
 import static java.util.Objects.*;
 
@@ -14,7 +14,7 @@
 import org.springframework.web.util.pattern.PathPattern;
 import org.springframework.web.util.pattern.PathPatternParser;
 
-import com.moabam.api.domain.entity.enums.Role;
+import com.moabam.api.domain.member.Role;
 
 import lombok.Builder;
 import lombok.Singular;
diff --git a/src/main/java/com/moabam/api/dto/AuthorizationMember.java b/src/main/java/com/moabam/global/auth/model/AuthorizationMember.java
similarity index 50%
rename from src/main/java/com/moabam/api/dto/AuthorizationMember.java
rename to src/main/java/com/moabam/global/auth/model/AuthorizationMember.java
index 2924d307..cd0b2306 100644
--- a/src/main/java/com/moabam/api/dto/AuthorizationMember.java
+++ b/src/main/java/com/moabam/global/auth/model/AuthorizationMember.java
@@ -1,6 +1,6 @@
-package com.moabam.api.dto;
+package com.moabam.global.auth.model;
 
-import com.moabam.api.domain.entity.enums.Role;
+import com.moabam.api.domain.member.Role;
 
 public record AuthorizationMember(
 	Long id,
diff --git a/src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java b/src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java
similarity index 87%
rename from src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java
rename to src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java
index 0174b764..426dd86a 100644
--- a/src/main/java/com/moabam/global/common/util/AuthorizationThreadLocal.java
+++ b/src/main/java/com/moabam/global/auth/model/AuthorizationThreadLocal.java
@@ -1,6 +1,4 @@
-package com.moabam.global.common.util;
-
-import com.moabam.api.dto.AuthorizationMember;
+package com.moabam.global.auth.model;
 
 import lombok.AccessLevel;
 import lombok.NoArgsConstructor;
diff --git a/src/main/java/com/moabam/api/dto/PublicClaim.java b/src/main/java/com/moabam/global/auth/model/PublicClaim.java
similarity index 69%
rename from src/main/java/com/moabam/api/dto/PublicClaim.java
rename to src/main/java/com/moabam/global/auth/model/PublicClaim.java
index e67c4fc5..cc23bdec 100644
--- a/src/main/java/com/moabam/api/dto/PublicClaim.java
+++ b/src/main/java/com/moabam/global/auth/model/PublicClaim.java
@@ -1,7 +1,7 @@
-package com.moabam.api.dto;
+package com.moabam.global.auth.model;
 
 import com.fasterxml.jackson.annotation.JsonIgnore;
-import com.moabam.api.domain.entity.enums.Role;
+import com.moabam.api.domain.member.Role;
 
 import lombok.Builder;
 
diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java
index 7de73c53..a7db79fb 100644
--- a/src/main/java/com/moabam/global/config/WebConfig.java
+++ b/src/main/java/com/moabam/global/config/WebConfig.java
@@ -8,9 +8,9 @@
 import org.springframework.web.servlet.config.annotation.CorsRegistry;
 import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
 
-import com.moabam.api.dto.PathMapper;
-import com.moabam.global.common.handler.CurrentMemberArgumentResolver;
-import com.moabam.global.common.handler.PathResolver;
+import com.moabam.api.application.auth.mapper.PathMapper;
+import com.moabam.global.auth.handler.CurrentMemberArgumentResolver;
+import com.moabam.global.auth.handler.PathResolver;
 
 @Configuration
 public class WebConfig implements WebMvcConfigurer {
diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html
index 89b81eb9..47b45ffe 100644
--- a/src/main/resources/static/docs/coupon.html
+++ b/src/main/resources/static/docs/coupon.html
@@ -1,2135 +1,464 @@
 
 
 
-    
-    
-    
-    
-    쿠폰(Coupon)
-    
-    
+
+
+
+
+쿠폰(Coupon)
+
+
 
 
 
 
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 192
@@ -2144,74 +473,74 @@ 

요청

"startAt" : "2023-01-01T00:00", "endAt" : "2023-02-01T00:00" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 64
+Content-Length: 62
 
 {
   "message" : "쿠폰의 이름이 중복되었습니다."
 }
-
-
-
-
-
-

쿠폰 삭제

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

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/77777777777 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 58
+Content-Length: 56
 
 {
   "message" : "존재하지 않는 쿠폰입니다."
 }
-
-
-
-
-
-

특정 쿠폰 조회

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

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -2222,22 +551,22 @@ 

응답

{ "message" : "존재하지 않는 쿠폰입니다." }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

+
+
POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 84
@@ -2248,11 +577,11 @@ 

요청

"couponNotStarted" : false, "couponEnded" : false }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -2261,45 +590,45 @@ 

응답

Content-Length: 3 [ ]
-
-
-
-
-
-
-

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

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

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

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

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

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

쿠폰 발급 (진행 중)

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
- + \ No newline at end of file diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 3e40058e..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index b82bdbff..206bb94a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -1,2165 +1,494 @@ - - - - - 알림(Notification) - - + + + + +알림(Notification) + +
-
-

알림(Notification)

-
-
-
-
콕 찌르기 알림 기능을 제공합니다.
-
-
-
-

콕 찌르기 알림

-
-
+
+

알림(Notification)

+
+
+
+
콕 찌르기 알림 기능을 제공합니다.
+
+
+
+

콕 찌르기 알림

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

요청

-
-
+
+
+

요청

+
+
GET /notifications/rooms/3/members/3 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 66
+Content-Length: 64
 
 {
   "message" : "이미 콕 알림을 보낸 대상입니다."
 }
-
-
-
-
-
+
+
+
+
+
- + \ No newline at end of file diff --git a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index e83f0e43..53a2ed0a 100644 --- a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -22,14 +22,18 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.util.ReflectionTestUtils; -import com.moabam.api.dto.AuthorizationCodeRequest; -import com.moabam.api.dto.AuthorizationCodeResponse; -import com.moabam.api.dto.AuthorizationMapper; -import com.moabam.api.dto.AuthorizationTokenInfoResponse; -import com.moabam.api.dto.AuthorizationTokenRequest; -import com.moabam.api.dto.AuthorizationTokenResponse; -import com.moabam.api.dto.LoginResponse; -import com.moabam.api.dto.PublicClaim; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +import com.moabam.api.application.auth.mapper.AuthorizationMapper; +import com.moabam.api.application.member.MemberService; +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.global.auth.model.PublicClaim; import com.moabam.global.config.OAuthConfig; import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.BadRequestException; diff --git a/src/test/java/com/moabam/api/application/BugServiceTest.java b/src/test/java/com/moabam/api/application/BugServiceTest.java index 72c06ae6..27b97d1a 100644 --- a/src/test/java/com/moabam/api/application/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/BugServiceTest.java @@ -11,9 +11,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.domain.entity.Bug; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.dto.BugResponse; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.member.Member; +import com.moabam.api.dto.bug.BugResponse; @ExtendWith(MockitoExtension.class) class BugServiceTest { diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/CouponServiceTest.java index d010f3b0..b234e420 100644 --- a/src/test/java/com/moabam/api/application/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/CouponServiceTest.java @@ -16,13 +16,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.domain.entity.Coupon; -import com.moabam.api.domain.entity.enums.CouponType; -import com.moabam.api.domain.repository.CouponRepository; -import com.moabam.api.domain.repository.CouponSearchRepository; -import com.moabam.api.dto.CouponResponse; -import com.moabam.api.dto.CouponSearchRequest; -import com.moabam.api.dto.CreateCouponRequest; +import com.moabam.api.application.coupon.CouponService; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.dto.coupon.CouponResponse; +import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; diff --git a/src/test/java/com/moabam/api/application/ImageServiceTest.java b/src/test/java/com/moabam/api/application/ImageServiceTest.java index 2a76c5f7..e7b7e0ca 100644 --- a/src/test/java/com/moabam/api/application/ImageServiceTest.java +++ b/src/test/java/com/moabam/api/application/ImageServiceTest.java @@ -15,8 +15,9 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.domain.resizedimage.ImageType; -import com.moabam.api.domain.resizedimage.ResizedImage; +import com.moabam.api.application.image.ImageService; +import com.moabam.api.domain.image.ImageType; +import com.moabam.api.domain.image.ResizedImage; import com.moabam.api.infrastructure.s3.S3Manager; import com.moabam.support.fixture.RoomFixture; diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/ItemServiceTest.java index 54eead85..57f69232 100644 --- a/src/test/java/com/moabam/api/application/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/ItemServiceTest.java @@ -19,20 +19,22 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.domain.entity.BugHistory; -import com.moabam.api.domain.entity.Inventory; -import com.moabam.api.domain.entity.Item; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.enums.BugType; -import com.moabam.api.domain.entity.enums.ItemType; -import com.moabam.api.domain.repository.BugHistoryRepository; -import com.moabam.api.domain.repository.InventoryRepository; -import com.moabam.api.domain.repository.InventorySearchRepository; -import com.moabam.api.domain.repository.ItemRepository; -import com.moabam.api.domain.repository.ItemSearchRepository; -import com.moabam.api.dto.ItemResponse; -import com.moabam.api.dto.ItemsResponse; -import com.moabam.api.dto.PurchaseItemRequest; +import com.moabam.api.application.item.ItemService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.bug.BugHistory; +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; diff --git a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java index addbd4fd..ffca19c6 100644 --- a/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/JwtAuthenticationServiceTest.java @@ -16,7 +16,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.dto.PublicClaim; +import com.moabam.api.application.auth.JwtAuthenticationService; +import com.moabam.api.application.auth.JwtProviderService; +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; diff --git a/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java index c511145a..05457843 100644 --- a/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java +++ b/src/test/java/com/moabam/api/application/JwtProviderServiceTest.java @@ -10,7 +10,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.moabam.api.dto.PublicClaim; +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.config.TokenConfig; import com.moabam.support.fixture.PublicClaimFixture; diff --git a/src/test/java/com/moabam/api/application/MemberServiceTest.java b/src/test/java/com/moabam/api/application/MemberServiceTest.java index 1d3fc62b..176c39cf 100644 --- a/src/test/java/com/moabam/api/application/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/MemberServiceTest.java @@ -12,10 +12,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.repository.MemberRepository; -import com.moabam.api.dto.AuthorizationTokenInfoResponse; -import com.moabam.api.dto.LoginResponse; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; +import com.moabam.api.dto.auth.LoginResponse; import com.moabam.support.fixture.AuthorizationResponseFixture; import com.moabam.support.fixture.MemberFixture; diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index 2e8d99d1..31eac5d9 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -17,11 +17,13 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.repository.NotificationRepository; -import com.moabam.api.domain.repository.ParticipantSearchRepository; -import com.moabam.api.dto.KnockNotificationStatusResponse; -import com.moabam.global.common.annotation.MemberTest; +import com.moabam.api.application.notification.NotificationService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.dto.notification.KnockNotificationStatusResponse; +import com.moabam.api.infrastructure.redis.NotificationRepository; +import com.moabam.global.auth.annotation.MemberTest; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java index c54b93f5..b9b0e828 100644 --- a/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java +++ b/src/test/java/com/moabam/api/application/OAuth2AuthorizationServerRequestServiceTest.java @@ -29,8 +29,9 @@ import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import com.moabam.api.dto.AuthorizationTokenInfoResponse; -import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +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; diff --git a/src/test/java/com/moabam/api/application/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java index c4555b94..24b331a6 100644 --- a/src/test/java/com/moabam/api/application/ProductServiceTest.java +++ b/src/test/java/com/moabam/api/application/ProductServiceTest.java @@ -13,10 +13,11 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.domain.entity.Product; -import com.moabam.api.domain.repository.ProductRepository; -import com.moabam.api.dto.ProductResponse; -import com.moabam.api.dto.ProductsResponse; +import com.moabam.api.application.product.ProductService; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.product.ProductResponse; +import com.moabam.api.dto.product.ProductsResponse; import com.moabam.global.common.util.StreamUtils; @ExtendWith(MockitoExtension.class) diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/RoomServiceTest.java index f8b438ad..0ed0b004 100644 --- a/src/test/java/com/moabam/api/application/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/RoomServiceTest.java @@ -1,6 +1,6 @@ package com.moabam.api.application; -import static com.moabam.api.domain.entity.enums.RoomType.*; +import static com.moabam.api.domain.room.RoomType.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -22,23 +22,26 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.domain.entity.DailyMemberCertification; -import com.moabam.api.domain.entity.DailyRoomCertification; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.entity.Routine; -import com.moabam.api.domain.repository.CertificationRepository; -import com.moabam.api.domain.repository.CertificationsSearchRepository; -import com.moabam.api.domain.repository.DailyMemberCertificationRepository; -import com.moabam.api.domain.repository.DailyRoomCertificationRepository; -import com.moabam.api.domain.repository.ParticipantRepository; -import com.moabam.api.domain.repository.ParticipantSearchRepository; -import com.moabam.api.domain.repository.RoomRepository; -import com.moabam.api.domain.repository.RoutineRepository; -import com.moabam.api.domain.resizedimage.ImageType; -import com.moabam.api.dto.CreateRoomRequest; -import com.moabam.api.dto.RoomMapper; +import com.moabam.api.application.image.ImageService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.room.RoomService; +import com.moabam.api.application.room.mapper.RoomMapper; +import com.moabam.api.domain.image.ImageType; +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.CreateRoomRequest; import com.moabam.global.common.util.ClockHolder; import com.moabam.support.fixture.MemberFixture; import com.moabam.support.fixture.RoomFixture; diff --git a/src/test/java/com/moabam/api/domain/entity/BugTest.java b/src/test/java/com/moabam/api/domain/entity/BugTest.java index 03b906dd..6a00a0f9 100644 --- a/src/test/java/com/moabam/api/domain/entity/BugTest.java +++ b/src/test/java/com/moabam/api/domain/entity/BugTest.java @@ -10,7 +10,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import com.moabam.api.domain.entity.enums.BugType; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.bug.BugType; import com.moabam.global.error.exception.BadRequestException; class BugTest { diff --git a/src/test/java/com/moabam/api/domain/entity/CertificationTest.java b/src/test/java/com/moabam/api/domain/entity/CertificationTest.java index 1e14bf29..1a7ac251 100644 --- a/src/test/java/com/moabam/api/domain/entity/CertificationTest.java +++ b/src/test/java/com/moabam/api/domain/entity/CertificationTest.java @@ -5,7 +5,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.moabam.api.domain.entity.enums.RoomType; +import com.moabam.api.domain.room.Certification; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; +import com.moabam.api.domain.room.Routine; class CertificationTest { diff --git a/src/test/java/com/moabam/api/domain/entity/CouponTest.java b/src/test/java/com/moabam/api/domain/entity/CouponTest.java index b517c83e..0e38a1e8 100644 --- a/src/test/java/com/moabam/api/domain/entity/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/entity/CouponTest.java @@ -7,7 +7,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.moabam.api.domain.entity.enums.CouponType; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.CouponFixture; diff --git a/src/test/java/com/moabam/api/domain/entity/ItemTest.java b/src/test/java/com/moabam/api/domain/entity/ItemTest.java index 1441732e..be0b518b 100644 --- a/src/test/java/com/moabam/api/domain/entity/ItemTest.java +++ b/src/test/java/com/moabam/api/domain/entity/ItemTest.java @@ -10,7 +10,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import com.moabam.api.domain.entity.enums.BugType; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.item.Item; import com.moabam.global.error.exception.BadRequestException; class ItemTest { diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index 01c89306..f3799eec 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import com.moabam.api.domain.entity.enums.Role; +import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; import com.moabam.global.common.util.BaseImageUrl; import com.moabam.support.fixture.MemberFixture; diff --git a/src/test/java/com/moabam/api/domain/entity/ProductTest.java b/src/test/java/com/moabam/api/domain/entity/ProductTest.java index 34780ecd..f960a30a 100644 --- a/src/test/java/com/moabam/api/domain/entity/ProductTest.java +++ b/src/test/java/com/moabam/api/domain/entity/ProductTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.moabam.api.domain.product.Product; import com.moabam.global.error.exception.BadRequestException; class ProductTest { diff --git a/src/test/java/com/moabam/api/domain/entity/RoomTest.java b/src/test/java/com/moabam/api/domain/entity/RoomTest.java index d4516b62..236db83f 100644 --- a/src/test/java/com/moabam/api/domain/entity/RoomTest.java +++ b/src/test/java/com/moabam/api/domain/entity/RoomTest.java @@ -7,7 +7,8 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import com.moabam.api.domain.entity.enums.RoomType; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.RoomType; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java index 4ceed56f..e80c5a61 100644 --- a/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java +++ b/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java @@ -5,6 +5,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import com.moabam.api.domain.coupon.CouponType; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java index bedca849..361780be 100644 --- a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java @@ -1,6 +1,6 @@ package com.moabam.api.domain.repository; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import java.time.LocalDate; import java.util.List; @@ -10,12 +10,19 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import com.moabam.api.domain.entity.Certification; -import com.moabam.api.domain.entity.DailyMemberCertification; -import com.moabam.api.domain.entity.DailyRoomCertification; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.entity.Routine; +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.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.RoomRepository; +import com.moabam.api.domain.room.repository.RoutineRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; import com.moabam.support.fixture.RoomFixture; diff --git a/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java index d817de8a..0f59a566 100644 --- a/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java @@ -14,8 +14,10 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import com.moabam.api.domain.entity.Coupon; -import com.moabam.api.dto.CouponSearchRequest; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.global.config.JpaConfig; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java index c8e78153..babd5c2e 100644 --- a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java @@ -13,10 +13,14 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import com.moabam.api.domain.entity.Inventory; -import com.moabam.api.domain.entity.Item; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.enums.ItemType; +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.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; @QuerydslRepositoryTest diff --git a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java index a1bc7751..6e7e16ff 100644 --- a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java @@ -11,8 +11,11 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import com.moabam.api.domain.entity.Item; -import com.moabam.api.domain.entity.enums.ItemType; +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.domain.item.repository.ItemSearchRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; @QuerydslRepositoryTest diff --git a/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java index 9ae4b3f9..8cbe611b 100644 --- a/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/MemberRepositoryTest.java @@ -5,7 +5,8 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; -import com.moabam.api.domain.entity.Member; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; import com.moabam.support.fixture.MemberFixture; diff --git a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java index 7f4782ff..0ca65307 100644 --- a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java @@ -12,7 +12,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.global.common.repository.StringRedisRepository; +import com.moabam.api.infrastructure.redis.NotificationRepository; +import com.moabam.api.infrastructure.redis.StringRedisRepository; @ExtendWith(MockitoExtension.class) class NotificationRepositoryTest { diff --git a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java index 4039d2c5..2f703ca2 100644 --- a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java @@ -11,8 +11,11 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.Import; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; +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.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoomRepository; import com.moabam.global.config.JpaConfig; @DataJpaTest diff --git a/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java index 18d2c481..279b30df 100644 --- a/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java +++ b/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.moabam.api.dto.coupon.CreateCouponRequest; class CreateCouponRequestTest { diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 4c6700d3..975258d6 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -17,9 +17,9 @@ import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.application.BugService; -import com.moabam.api.dto.BugMapper; -import com.moabam.api.dto.BugResponse; +import com.moabam.api.application.bug.BugMapper; +import com.moabam.api.application.bug.BugService; +import com.moabam.api.dto.bug.BugResponse; import com.moabam.support.common.WithoutFilterSupporter; @WebMvcTest(controllers = BugController.class) diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 1fc2e129..fb170675 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -22,12 +22,12 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.domain.entity.Coupon; -import com.moabam.api.domain.entity.enums.CouponType; -import com.moabam.api.domain.repository.CouponRepository; -import com.moabam.api.dto.CouponMapper; -import com.moabam.api.dto.CouponSearchRequest; -import com.moabam.api.dto.CreateCouponRequest; +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.repository.CouponRepository; +import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.CouponFixture; diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 3eaf9be1..2ccbc7c4 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -20,13 +20,13 @@ import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.application.ItemService; -import com.moabam.api.domain.entity.Item; -import com.moabam.api.domain.entity.enums.BugType; -import com.moabam.api.domain.entity.enums.ItemType; -import com.moabam.api.dto.ItemMapper; -import com.moabam.api.dto.ItemsResponse; -import com.moabam.api.dto.PurchaseItemRequest; +import com.moabam.api.application.item.ItemMapper; +import com.moabam.api.application.item.ItemService; +import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemType; +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; diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 402017e8..db6b248e 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -32,11 +32,11 @@ import org.springframework.web.util.UriComponentsBuilder; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.application.AuthorizationService; -import com.moabam.api.application.OAuth2AuthorizationServerRequestService; -import com.moabam.api.dto.AuthorizationCodeResponse; -import com.moabam.api.dto.AuthorizationTokenInfoResponse; -import com.moabam.api.dto.AuthorizationTokenResponse; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +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.common.util.GlobalConstant; import com.moabam.global.config.OAuthConfig; import com.moabam.global.error.handler.RestTemplateResponseHandler; diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index f9b2a7e5..2470e4fb 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -23,12 +23,12 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.repository.MemberRepository; -import com.moabam.api.domain.repository.NotificationRepository; -import com.moabam.api.domain.repository.RoomRepository; -import com.moabam.global.common.repository.StringRedisRepository; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.repository.MemberRepository; +import com.moabam.api.domain.room.Room; +import com.moabam.api.domain.room.repository.RoomRepository; +import com.moabam.api.infrastructure.redis.NotificationRepository; +import com.moabam.api.infrastructure.redis.StringRedisRepository; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.ErrorSnippetFixture; diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index 991c75a5..cd6ef826 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -19,10 +19,10 @@ import org.springframework.test.web.servlet.MockMvc; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.application.ProductService; -import com.moabam.api.domain.entity.Product; -import com.moabam.api.dto.ProductMapper; -import com.moabam.api.dto.ProductsResponse; +import com.moabam.api.application.product.ProductMapper; +import com.moabam.api.application.product.ProductService; +import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.product.ProductsResponse; import com.moabam.support.common.WithoutFilterSupporter; @WebMvcTest(controllers = ProductController.class) diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index bd4926fe..a3381e07 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1,6 +1,6 @@ package com.moabam.api.presentation; -import static com.moabam.api.domain.entity.enums.RoomType.*; +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.*; @@ -25,23 +25,23 @@ import org.springframework.transaction.annotation.Transactional; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.domain.entity.Certification; -import com.moabam.api.domain.entity.DailyMemberCertification; -import com.moabam.api.domain.entity.DailyRoomCertification; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.entity.Routine; -import com.moabam.api.domain.repository.CertificationRepository; -import com.moabam.api.domain.repository.DailyMemberCertificationRepository; -import com.moabam.api.domain.repository.DailyRoomCertificationRepository; -import com.moabam.api.domain.repository.MemberRepository; -import com.moabam.api.domain.repository.ParticipantRepository; -import com.moabam.api.domain.repository.RoomRepository; -import com.moabam.api.domain.repository.RoutineRepository; -import com.moabam.api.dto.CreateRoomRequest; -import com.moabam.api.dto.EnterRoomRequest; -import com.moabam.api.dto.ModifyRoomRequest; +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.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.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.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.BugFixture; diff --git a/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java b/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java index 99711dc7..eef1e9d9 100644 --- a/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java +++ b/src/test/java/com/moabam/global/common/handler/CurrentMemberArgumentResolverTest.java @@ -15,11 +15,12 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.ModelAndViewContainer; -import com.moabam.api.domain.entity.Member; -import com.moabam.api.domain.entity.enums.Role; -import com.moabam.api.dto.AuthorizationMember; -import com.moabam.global.common.annotation.CurrentMember; -import com.moabam.global.common.util.AuthorizationThreadLocal; +import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.handler.CurrentMemberArgumentResolver; +import com.moabam.global.auth.model.AuthorizationMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; @ExtendWith(MockitoExtension.class) class CurrentMemberArgumentResolverTest { diff --git a/src/test/java/com/moabam/global/common/handler/PathResolverTest.java b/src/test/java/com/moabam/global/common/handler/PathResolverTest.java index f77cd436..69ec965f 100644 --- a/src/test/java/com/moabam/global/common/handler/PathResolverTest.java +++ b/src/test/java/com/moabam/global/common/handler/PathResolverTest.java @@ -1,6 +1,6 @@ package com.moabam.global.common.handler; -import static com.moabam.api.domain.entity.enums.Role.*; +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.*; @@ -10,7 +10,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.moabam.global.common.handler.PathResolver; +import com.moabam.global.auth.handler.PathResolver; class PathResolverTest { diff --git a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java index 40af28e0..5bdd86d1 100644 --- a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java @@ -11,6 +11,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; +import com.moabam.api.infrastructure.redis.StringRedisRepository; import com.moabam.global.config.EmbeddedRedisConfig; import com.moabam.global.config.RedisConfig; diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java index 0e762df5..f4a029ed 100644 --- a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -18,12 +18,13 @@ import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.web.servlet.HandlerExceptionResolver; -import com.moabam.api.application.AuthorizationService; -import com.moabam.api.application.JwtAuthenticationService; -import com.moabam.api.application.JwtProviderService; -import com.moabam.api.dto.AuthorizationMember; -import com.moabam.api.dto.PublicClaim; -import com.moabam.global.common.util.AuthorizationThreadLocal; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.JwtAuthenticationService; +import com.moabam.api.application.auth.JwtProviderService; +import com.moabam.global.auth.filter.AuthorizationFilter; +import com.moabam.global.auth.model.AuthorizationMember; +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; diff --git a/src/test/java/com/moabam/global/filter/PathFilterTest.java b/src/test/java/com/moabam/global/filter/PathFilterTest.java index 3429bb0a..33d24808 100644 --- a/src/test/java/com/moabam/global/filter/PathFilterTest.java +++ b/src/test/java/com/moabam/global/filter/PathFilterTest.java @@ -19,7 +19,8 @@ import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; -import com.moabam.global.common.handler.PathResolver; +import com.moabam.global.auth.filter.PathFilter; +import com.moabam.global.auth.handler.PathResolver; import jakarta.servlet.ServletException; diff --git a/src/test/java/com/moabam/support/annotation/WithMember.java b/src/test/java/com/moabam/support/annotation/WithMember.java index 8cfef2f1..20c4f334 100644 --- a/src/test/java/com/moabam/support/annotation/WithMember.java +++ b/src/test/java/com/moabam/support/annotation/WithMember.java @@ -5,7 +5,7 @@ import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; -import com.moabam.api.domain.entity.enums.Role; +import com.moabam.api.domain.member.Role; @Target({ElementType.METHOD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) diff --git a/src/test/java/com/moabam/support/common/FilterProcessExtension.java b/src/test/java/com/moabam/support/common/FilterProcessExtension.java index 391007dc..f69a431a 100644 --- a/src/test/java/com/moabam/support/common/FilterProcessExtension.java +++ b/src/test/java/com/moabam/support/common/FilterProcessExtension.java @@ -11,8 +11,8 @@ import org.junit.jupiter.api.extension.ParameterResolutionException; import org.junit.jupiter.api.extension.ParameterResolver; -import com.moabam.api.dto.AuthorizationMember; -import com.moabam.global.common.util.AuthorizationThreadLocal; +import com.moabam.global.auth.model.AuthorizationMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.support.annotation.WithMember; public class FilterProcessExtension implements BeforeEachCallback, AfterEachCallback, ParameterResolver { diff --git a/src/test/java/com/moabam/support/common/WithFilterSupporter.java b/src/test/java/com/moabam/support/common/WithFilterSupporter.java index c5e198ca..9648687f 100644 --- a/src/test/java/com/moabam/support/common/WithFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithFilterSupporter.java @@ -12,7 +12,7 @@ import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; -import com.moabam.api.application.JwtProviderService; +import com.moabam.api.application.auth.JwtProviderService; import com.moabam.global.common.util.CookieUtils; import com.moabam.global.config.TokenConfig; import com.moabam.support.fixture.PublicClaimFixture; diff --git a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java index 189e6536..f45b8d2c 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -9,10 +9,10 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.boot.test.mock.mockito.MockBean; -import com.moabam.api.application.AuthorizationService; -import com.moabam.api.application.JwtAuthenticationService; -import com.moabam.api.domain.entity.enums.Role; -import com.moabam.global.common.handler.PathResolver; +import com.moabam.api.application.auth.AuthorizationService; +import com.moabam.api.application.auth.JwtAuthenticationService; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.handler.PathResolver; @ExtendWith({FilterProcessExtension.class}) public class WithoutFilterSupporter { diff --git a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java index a30f0e02..6c55a775 100644 --- a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java +++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java @@ -4,9 +4,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; -import com.moabam.api.domain.repository.CertificationsSearchRepository; -import com.moabam.api.domain.repository.InventorySearchRepository; -import com.moabam.api.domain.repository.ItemSearchRepository; +import com.moabam.api.domain.item.repository.InventorySearchRepository; +import com.moabam.api.domain.item.repository.ItemSearchRepository; +import com.moabam.api.domain.room.repository.CertificationsSearchRepository; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.EntityManager; diff --git a/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java index c1671fb7..c8e00e7f 100644 --- a/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java +++ b/src/test/java/com/moabam/support/fixture/AuthorizationResponseFixture.java @@ -1,8 +1,8 @@ package com.moabam.support.fixture; -import com.moabam.api.dto.AuthorizationCodeResponse; -import com.moabam.api.dto.AuthorizationTokenInfoResponse; -import com.moabam.api.dto.AuthorizationTokenResponse; +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 { diff --git a/src/test/java/com/moabam/support/fixture/BugFixture.java b/src/test/java/com/moabam/support/fixture/BugFixture.java index 91eb772e..a2584bcd 100644 --- a/src/test/java/com/moabam/support/fixture/BugFixture.java +++ b/src/test/java/com/moabam/support/fixture/BugFixture.java @@ -1,6 +1,6 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.Bug; +import com.moabam.api.domain.bug.Bug; public final class BugFixture { diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 47438806..702d0a7c 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -6,10 +6,10 @@ import org.junit.jupiter.params.provider.Arguments; -import com.moabam.api.domain.entity.Coupon; -import com.moabam.api.domain.entity.enums.CouponType; -import com.moabam.api.dto.CouponSearchRequest; -import com.moabam.api.dto.CreateCouponRequest; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponType; +import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CreateCouponRequest; public final class CouponFixture { diff --git a/src/test/java/com/moabam/support/fixture/InventoryFixture.java b/src/test/java/com/moabam/support/fixture/InventoryFixture.java index 9c060c20..c1be1228 100644 --- a/src/test/java/com/moabam/support/fixture/InventoryFixture.java +++ b/src/test/java/com/moabam/support/fixture/InventoryFixture.java @@ -1,7 +1,7 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.Inventory; -import com.moabam.api.domain.entity.Item; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.item.Item; public class InventoryFixture { diff --git a/src/test/java/com/moabam/support/fixture/ItemFixture.java b/src/test/java/com/moabam/support/fixture/ItemFixture.java index 73fb9e78..087b87d3 100644 --- a/src/test/java/com/moabam/support/fixture/ItemFixture.java +++ b/src/test/java/com/moabam/support/fixture/ItemFixture.java @@ -1,8 +1,8 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.Item; -import com.moabam.api.domain.entity.enums.ItemCategory; -import com.moabam.api.domain.entity.enums.ItemType; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.ItemCategory; +import com.moabam.api.domain.item.ItemType; public class ItemFixture { diff --git a/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java index 951459aa..7a3c530a 100644 --- a/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java +++ b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java @@ -1,6 +1,6 @@ package com.moabam.support.fixture; -import com.moabam.api.application.JwtProviderService; +import com.moabam.api.application.auth.JwtProviderService; import com.moabam.global.config.TokenConfig; public class JwtProviderFixture { diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java index e1b4d000..93551ff7 100644 --- a/src/test/java/com/moabam/support/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -1,6 +1,6 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.Member; +import com.moabam.api.domain.member.Member; public final class MemberFixture { diff --git a/src/test/java/com/moabam/support/fixture/ParticipantFixture.java b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java index 08bdcbeb..e8d60215 100644 --- a/src/test/java/com/moabam/support/fixture/ParticipantFixture.java +++ b/src/test/java/com/moabam/support/fixture/ParticipantFixture.java @@ -5,8 +5,8 @@ import org.junit.jupiter.params.provider.Arguments; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; +import com.moabam.api.domain.room.Participant; +import com.moabam.api.domain.room.Room; public final class ParticipantFixture { diff --git a/src/test/java/com/moabam/support/fixture/ProductFixture.java b/src/test/java/com/moabam/support/fixture/ProductFixture.java index e8b2b3fd..de5bcff6 100644 --- a/src/test/java/com/moabam/support/fixture/ProductFixture.java +++ b/src/test/java/com/moabam/support/fixture/ProductFixture.java @@ -1,7 +1,7 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.Product; -import com.moabam.api.domain.entity.enums.ProductType; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.ProductType; public class ProductFixture { diff --git a/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java b/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java index 9e6d2e08..06bbb415 100644 --- a/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java +++ b/src/test/java/com/moabam/support/fixture/PublicClaimFixture.java @@ -1,7 +1,7 @@ package com.moabam.support.fixture; -import com.moabam.api.domain.entity.enums.Role; -import com.moabam.api.dto.PublicClaim; +import com.moabam.api.domain.member.Role; +import com.moabam.global.auth.model.PublicClaim; public class PublicClaimFixture { diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java index 95732c76..4a436205 100644 --- a/src/test/java/com/moabam/support/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -9,13 +9,13 @@ import org.springframework.mock.web.MockMultipartFile; -import com.moabam.api.domain.entity.Certification; -import com.moabam.api.domain.entity.DailyMemberCertification; -import com.moabam.api.domain.entity.DailyRoomCertification; -import com.moabam.api.domain.entity.Participant; -import com.moabam.api.domain.entity.Room; -import com.moabam.api.domain.entity.Routine; -import com.moabam.api.domain.entity.enums.RoomType; +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 { From ca77e2b784bee255fcccf08dc9af81ed9b9c5e52 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Tue, 14 Nov 2023 15:05:26 +0900 Subject: [PATCH 037/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?(#79)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: gitignore 추가 * refactor: Room 관련 Service 분리 --- .gitignore | 1 + .../room/RoomCertificationService.java | 162 ++++++++++++ .../application/room/RoomSearchService.java | 164 ++++++++++++ .../api/application/room/RoomService.java | 249 ------------------ .../api/presentation/RoomController.java | 8 +- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../RoomCertificationServiceTest.java | 183 +++++++++++++ .../api/application/RoomServiceTest.java | 135 ---------- 10 files changed, 519 insertions(+), 389 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/room/RoomCertificationService.java create mode 100644 src/main/java/com/moabam/api/application/room/RoomSearchService.java create mode 100644 src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java diff --git a/.gitignore b/.gitignore index 43d3e28b..1bd49ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -122,4 +122,5 @@ logs/ application-*.yml src/main/resources/config !application-test.yml +src/main/generated dump.rdb diff --git a/src/main/java/com/moabam/api/application/room/RoomCertificationService.java b/src/main/java/com/moabam/api/application/room/RoomCertificationService.java new file mode 100644 index 00000000..c85cc1bd --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/RoomCertificationService.java @@ -0,0 +1,162 @@ +package com.moabam.api.application.room; + +import static com.moabam.api.domain.image.ImageType.*; +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 org.springframework.web.multipart.MultipartFile; + +import com.moabam.api.application.image.ImageService; +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.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 RoomCertificationService { + + 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 ImageService imageService; + private final ClockHolder clockHolder; + + @Transactional + public void certifyRoom(Long memberId, Long roomId, List multipartFiles) { + LocalDate today = LocalDate.now(); + Participant participant = participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + Room room = participant.getRoom(); + Member member = memberService.getById(memberId); + BugType bugType = switch (room.getRoomType()) { + case MORNING -> BugType.MORNING; + case NIGHT -> BugType.NIGHT; + }; + int roomLevel = room.getLevel(); + + validateCertifyTime(clockHolder.times(), room.getCertifyTime()); + validateAlreadyCertified(memberId, roomId, today); + + DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, + roomId, participant); + dailyMemberCertificationRepository.save(dailyMemberCertification); + + member.increaseTotalCertifyCount(); + + List result = imageService.uploadImages(multipartFiles, CERTIFICATION); + saveNewCertifications(result, memberId); + + Optional dailyRoomCertification = + certificationsSearchRepository.findDailyRoomCertification(roomId, today); + + if (dailyRoomCertification.isEmpty()) { + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + if (completePercentage >= 75) { + DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( + roomId, today); + + dailyRoomCertificationRepository.save(createDailyRoomCertification); + + int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); + + List memberIds = dailyMemberCertifications.stream() + .map(DailyMemberCertification::getMemberId) + .toList(); + + memberService.getRoomMembers(memberIds) + .forEach(completedMember -> completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); + + return; + } + } + + if (dailyRoomCertification.isPresent()) { + member.getBug().increaseBug(bugType, roomLevel); + } + } + + private void validateCertifyTime(LocalDateTime now, int certifyTime) { + LocalTime targetTime = LocalTime.of(certifyTime, 0); + LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); + LocalDateTime plusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).plusMinutes(10); + + if (now.isBefore(minusTenMinutes) || 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 saveNewCertifications(List imageUrls, Long memberId) { + 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 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(); + } +} diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java new file mode 100644 index 00000000..ff6fb40e --- /dev/null +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -0,0 +1,164 @@ +package com.moabam.api.application.room; + +import static com.moabam.global.error.model.ErrorMessage.*; + +import java.time.LocalDate; +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.room.mapper.CertificationsMapper; +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.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.domain.room.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoutineSearchRepository; +import com.moabam.api.dto.room.CertificationImageResponse; +import com.moabam.api.dto.room.RoomDetailsResponse; +import com.moabam.api.dto.room.RoutineResponse; +import com.moabam.api.dto.room.TodayCertificateRankResponse; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class RoomSearchService { + + private final CertificationsSearchRepository certificationsSearchRepository; + private final ParticipantSearchRepository participantSearchRepository; + private final RoutineSearchRepository routineSearchRepository; + private final MemberService memberService; + + public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { + LocalDate today = LocalDate.now(); + Participant participant = participantSearchRepository.findOne(memberId, roomId) + .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); + Room room = participant.getRoom(); + + String managerNickname = memberService.getManager(roomId).getNickname(); + List dailyMemberCertifications = + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); + List routineResponses = getRoutineResponses(roomId); + List todayCertificateRankResponses = getTodayCertificateRankResponses(roomId, + dailyMemberCertifications, today); + List certifiedDates = getCertifiedDates(roomId, today); + double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), + room.getCurrentUserCount()); + + return RoomMapper.toRoomDetailsResponse(room, managerNickname, routineResponses, certifiedDates, + todayCertificateRankResponses, completePercentage); + } + + private List getRoutineResponses(Long roomId) { + List roomRoutines = routineSearchRepository.findByRoomId(roomId); + + return RoutineMapper.toRoutineResponses(roomRoutines); + } + + private List getTodayCertificateRankResponses(Long roomId, + List dailyMemberCertifications, LocalDate today) { + + List responses = new ArrayList<>(); + List certifications = certificationsSearchRepository.findCertifications(roomId, today); + List participants = participantSearchRepository.findParticipants(roomId); + List members = memberService.getRoomMembers(participants.stream() + .map(Participant::getMemberId) + .toList()); + + addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, today); + addUncompletedMembers(responses, dailyMemberCertifications, members, participants, today); + + return responses; + } + + private void addCompletedMembers(List responses, + List dailyMemberCertifications, List members, + List certifications, List participants, LocalDate today) { + + 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)); + + int contributionPoint = calculateContributionPoint(member.getId(), participants, today); + List certificationImageResponses = + CertificationsMapper.toCertificateImageResponses(member.getId(), certifications); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( + rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses); + + rank += 1; + responses.add(response); + } + } + + private void addUncompletedMembers(List responses, + List dailyMemberCertifications, List members, + List participants, LocalDate today) { + + List allMemberIds = participants.stream() + .map(Participant::getMemberId) + .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)); + + int contributionPoint = calculateContributionPoint(memberId, participants, today); + + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( + 500, member, contributionPoint, "https://~awake", "https://~sleep", null); + + responses.add(response); + } + } + + private int calculateContributionPoint(Long memberId, List participants, LocalDate today) { + Participant participant = participants.stream() + .filter(p -> p.getMemberId().equals(memberId)) + .findAny() + .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); + + int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), today).getDays() + 1; + + return (int)(((double)participant.getCertifyCount() / participatedDays) * 100); + } + + private List getCertifiedDates(Long roomId, LocalDate today) { + List certifications = certificationsSearchRepository.findDailyRoomCertifications( + roomId, today); + + return certifications.stream().map(DailyRoomCertification::getCertifiedAt).toList(); + } + + private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { + double completePercentage = ((double)certifiedMembersCount / currentsMembersCount) * 100; + + return Math.round(completePercentage * 100) / 100.0; + } +} diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 7b542fa1..4382fce5 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -1,56 +1,30 @@ package com.moabam.api.application.room; -import static com.moabam.api.domain.image.ImageType.*; import static com.moabam.api.domain.room.RoomType.*; import static com.moabam.global.error.model.ErrorMessage.*; -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.Optional; -import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.application.image.ImageService; import com.moabam.api.application.member.MemberService; -import com.moabam.api.application.room.mapper.CertificationsMapper; import com.moabam.api.application.room.mapper.RoomMapper; import com.moabam.api.application.room.mapper.RoutineMapper; -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.RoomType; 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.domain.room.repository.RoutineSearchRepository; -import com.moabam.api.dto.room.CertificationImageResponse; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; -import com.moabam.api.dto.room.RoomDetailsResponse; -import com.moabam.api.dto.room.RoutineResponse; -import com.moabam.api.dto.room.TodayCertificateRankResponse; -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.ForbiddenException; import com.moabam.global.error.exception.NotFoundException; @@ -67,13 +41,7 @@ public class RoomService { private final RoutineSearchRepository routineSearchRepository; private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; - private final CertificationsSearchRepository certificationsSearchRepository; - private final DailyMemberCertificationRepository dailyMemberCertificationRepository; - private final DailyRoomCertificationRepository dailyRoomCertificationRepository; - private final CertificationRepository certificationRepository; private final MemberService memberService; - private final ImageService imageService; - private final ClockHolder clockHolder; @Transactional public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @@ -151,82 +119,6 @@ public void exitRoom(Long memberId, Long roomId) { roomRepository.delete(room); } - public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { - LocalDate today = LocalDate.now(); - Participant participant = getParticipant(memberId, roomId); - Room room = participant.getRoom(); - - String managerNickname = memberService.getManager(roomId).getNickname(); - List dailyMemberCertifications = - certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); - List routineResponses = getRoutineResponses(roomId); - List todayCertificateRankResponses = getTodayCertificateRankResponses(roomId, - dailyMemberCertifications, today); - List certifiedDates = getCertifiedDates(roomId, today); - double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), - room.getCurrentUserCount()); - - return RoomMapper.toRoomDetailsResponse(room, managerNickname, routineResponses, certifiedDates, - todayCertificateRankResponses, completePercentage); - } - - @Transactional - public void certifyRoom(Long memberId, Long roomId, List multipartFiles) { - LocalDate today = LocalDate.now(); - Participant participant = getParticipant(memberId, roomId); - Room room = participant.getRoom(); - Member member = memberService.getById(memberId); - BugType bugType = switch (room.getRoomType()) { - case MORNING -> BugType.MORNING; - case NIGHT -> BugType.NIGHT; - }; - int roomLevel = room.getLevel(); - - validateCertifyTime(clockHolder.times(), room.getCertifyTime()); - validateAlreadyCertified(memberId, roomId, today); - - DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, - roomId, participant); - dailyMemberCertificationRepository.save(dailyMemberCertification); - - member.increaseTotalCertifyCount(); - - List result = imageService.uploadImages(multipartFiles, CERTIFICATION); - saveNewCertifications(result, memberId); - - Optional dailyRoomCertification = - certificationsSearchRepository.findDailyRoomCertification(roomId, today); - - if (dailyRoomCertification.isEmpty()) { - List dailyMemberCertifications = - certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); - double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), - room.getCurrentUserCount()); - - if (completePercentage >= 75) { - DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( - roomId, today); - - dailyRoomCertificationRepository.save(createDailyRoomCertification); - - int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); - - List memberIds = dailyMemberCertifications.stream() - .map(DailyMemberCertification::getMemberId) - .toList(); - - memberService.getRoomMembers(memberIds) - .forEach(completedMember -> completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); - - return; - } - } - - if (dailyRoomCertification.isPresent()) { - member.getBug().increaseBug(bugType, roomLevel); - } - } - public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); @@ -284,145 +176,4 @@ private void decreaseRoomCount(Long memberId, RoomType roomType) { member.exitNightRoom(); } - - private List getRoutineResponses(Long roomId) { - List roomRoutines = routineSearchRepository.findByRoomId(roomId); - - return RoutineMapper.toRoutineResponses(roomRoutines); - } - - private List getTodayCertificateRankResponses(Long roomId, - List dailyMemberCertifications, LocalDate today) { - - List responses = new ArrayList<>(); - List certifications = certificationsSearchRepository.findCertifications(roomId, today); - List participants = participantSearchRepository.findParticipants(roomId); - List members = memberService.getRoomMembers(participants.stream() - .map(Participant::getMemberId) - .toList()); - - addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, today); - addUncompletedMembers(responses, dailyMemberCertifications, members, participants, today); - - return responses; - } - - private void addCompletedMembers(List responses, - List dailyMemberCertifications, List members, - List certifications, List participants, LocalDate today) { - - 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)); - - int contributionPoint = calculateContributionPoint(member.getId(), participants, today); - List certificationImageResponses = - CertificationsMapper.toCertificateImageResponses(member.getId(), certifications); - - TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( - rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses); - - rank += 1; - responses.add(response); - } - } - - private void addUncompletedMembers(List responses, - List dailyMemberCertifications, List members, - List participants, LocalDate today) { - - List allMemberIds = participants.stream() - .map(Participant::getMemberId) - .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)); - - int contributionPoint = calculateContributionPoint(memberId, participants, today); - - TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( - 500, member, contributionPoint, "https://~awake", "https://~sleep", null); - - responses.add(response); - } - } - - private int calculateContributionPoint(Long memberId, List participants, LocalDate today) { - Participant participant = participants.stream() - .filter(p -> p.getMemberId().equals(memberId)) - .findAny() - .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); - - int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), today).getDays() + 1; - - return (int)(((double)participant.getCertifyCount() / participatedDays) * 100); - } - - private List getCertifiedDates(Long roomId, LocalDate today) { - List certifications = certificationsSearchRepository.findDailyRoomCertifications( - roomId, today); - - return certifications.stream().map(DailyRoomCertification::getCertifiedAt).toList(); - } - - private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { - double completePercentage = ((double)certifiedMembersCount / currentsMembersCount) * 100; - - return Math.round(completePercentage * 100) / 100.0; - } - - private void validateCertifyTime(LocalDateTime now, int certifyTime) { - LocalTime targetTime = LocalTime.of(certifyTime, 0); - LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); - LocalDateTime plusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).plusMinutes(10); - - if (now.isBefore(minusTenMinutes) || 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 saveNewCertifications(List imageUrls, Long memberId) { - 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 int getRoomLevelAfterExpApply(int roomLevel, Room room) { - int requireExp = RoomExp.of(roomLevel).getTotalExp(); - room.gainExp(); - - if (room.getExp() == requireExp) { - room.levelUp(); - } - - return room.getLevel(); - } } diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 55545600..dd3e1a89 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -15,6 +15,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import com.moabam.api.application.room.RoomCertificationService; +import com.moabam.api.application.room.RoomSearchService; import com.moabam.api.application.room.RoomService; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; @@ -30,6 +32,8 @@ public class RoomController { private final RoomService roomService; + private final RoomSearchService roomSearchService; + private final RoomCertificationService roomCertificationService; @PostMapping @ResponseStatus(HttpStatus.CREATED) @@ -59,12 +63,12 @@ public void exitRoom(@PathVariable("roomId") Long roomId) { @GetMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { - return roomService.getRoomDetails(1L, roomId); + return roomSearchService.getRoomDetails(1L, roomId); } @PostMapping("/{roomId}/certification") @ResponseStatus(HttpStatus.CREATED) public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { - roomService.certifyRoom(1L, roomId, multipartFiles); + roomCertificationService.certifyRoom(1L, roomId, multipartFiles); } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 47b45ffe..406bcda8 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 62f80fd6..8f28a0c8 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 0673186a..0e9f868c 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 975258d6..a27c9af6 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -1,6 +1,9 @@ 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.BugHistoryFixture.*; +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.*; @@ -9,20 +12,30 @@ 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.Test; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +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.bug.BugService; +import com.moabam.api.application.member.MemberService; +import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.repository.BugHistorySearchRepository; +import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; -@WebMvcTest(controllers = BugController.class) +@Transactional +@SpringBootTest +@AutoConfigureMockMvc class BugControllerTest extends WithoutFilterSupporter { @Autowired @@ -32,25 +45,56 @@ class BugControllerTest extends WithoutFilterSupporter { ObjectMapper objectMapper; @MockBean - BugService bugService; + MemberService memberService; + + @Autowired + BugHistoryRepository bugHistoryRepository; + + @Autowired + BugHistorySearchRepository bugHistorySearchRepository; @DisplayName("벌레를 조회한다.") + @WithMember @Test void get_bug_success() throws Exception { // given - Long memberId = 1L; + Long memberId = getAuthorizationMember().id(); BugResponse expected = BugMapper.toBugResponse(bug()); - given(bugService.getBug(memberId)).willReturn(expected); + given(memberService.getById(memberId)).willReturn(member()); - // when, then + // expected String content = mockMvc.perform(get("/bugs") .contentType(APPLICATION_JSON)) - .andDo(print()) .andExpect(status().isOk()) + .andDo(print()) .andReturn() .getResponse() .getContentAsString(UTF_8); BugResponse actual = objectMapper.readValue(content, BugResponse.class); assertThat(actual).isEqualTo(expected); } + + @DisplayName("오늘 보상 벌레를 조회한다.") + @WithMember + @Test + void get_today_bug_success() throws Exception { + // given + Long memberId = getAuthorizationMember().id(); + bugHistoryRepository.saveAll(List.of( + rewardMorningBug(memberId, 2), + rewardMorningBug(memberId, 3), + rewardNightBug(memberId, 5))); + TodayBugResponse expected = BugMapper.toTodayBugResponse(5, 5); + + // expected + String content = mockMvc.perform(get("/bugs/today") + .contentType(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andDo(print()) + .andReturn() + .getResponse() + .getContentAsString(UTF_8); + TodayBugResponse actual = objectMapper.readValue(content, TodayBugResponse.class); + assertThat(actual).isEqualTo(expected); + } } 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(); + } +} From 33a691b36a2229608f5181106ecdb08a8b3cdf68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Tue, 14 Nov 2023 16:52:32 +0900 Subject: [PATCH 040/185] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=95=8C=EB=A6=BC=20Authorization=20Member=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=08=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20?= =?UTF-8?q?(#82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: Coupon에 Authorization Member 적용 * test: Authorization Member 적용된 코드 테스트 --- .../api/application/coupon/CouponService.java | 16 ++++- .../notification/NotificationService.java | 14 ++--- .../api/presentation/CouponController.java | 11 ++-- .../presentation/NotificationController.java | 8 ++- .../global/auth/annotation/MemberTest.java | 8 --- .../api/application/CouponServiceTest.java | 59 ++++++++++++++++--- .../application/NotificationServiceTest.java | 44 +++++++++----- .../presentation/CouponControllerTest.java | 7 +++ .../NotificationControllerTest.java | 4 ++ 9 files changed, 123 insertions(+), 48 deletions(-) delete mode 100644 src/main/java/com/moabam/global/auth/annotation/MemberTest.java diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 88cca6f2..8e0c710d 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -9,9 +9,11 @@ import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -28,16 +30,18 @@ public class CouponService { private final CouponSearchRepository couponSearchRepository; @Transactional - public void createCoupon(Long adminId, CreateCouponRequest request) { + public void createCoupon(AuthorizationMember admin, CreateCouponRequest request) { + validateAdminRole(admin); validateConflictCouponName(request.name()); validateCouponPeriod(request.startAt(), request.endAt()); - Coupon coupon = CouponMapper.toEntity(adminId, request); + Coupon coupon = CouponMapper.toEntity(admin.id(), request); couponRepository.save(coupon); } @Transactional - public void deleteCoupon(Long adminId, Long couponId) { + public void deleteCoupon(AuthorizationMember admin, Long couponId) { + validateAdminRole(admin); Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); couponRepository.delete(coupon); @@ -59,6 +63,12 @@ public List getCoupons(CouponSearchRequest request) { .toList(); } + private void validateAdminRole(AuthorizationMember admin) { + if (!admin.role().equals(Role.ADMIN)) { + throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND); + } + } + private void validateConflictCouponName(String name) { if (couponRepository.existsByName(name)) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 0845042f..71e094f6 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -20,7 +20,7 @@ import com.moabam.api.domain.room.repository.ParticipantSearchRepository; import com.moabam.api.dto.notification.KnockNotificationStatusResponse; import com.moabam.api.infrastructure.redis.NotificationRepository; -import com.moabam.global.auth.annotation.MemberTest; +import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.model.ErrorMessage; @@ -38,10 +38,10 @@ public class NotificationService { private final ParticipantSearchRepository participantSearchRepository; @Transactional - public void sendKnockNotification(MemberTest member, Long targetId, Long roomId) { + public void sendKnockNotification(AuthorizationMember member, Long targetId, Long roomId) { roomService.validateRoomById(roomId); - String knockKey = generateKnockKey(member.memberId(), targetId, roomId); + String knockKey = generateKnockKey(member.id(), targetId, roomId); validateConflictKnockNotification(knockKey); validateFcmToken(targetId); @@ -65,12 +65,12 @@ public void sendCertificationTimeNotification() { /** * TODO : 영명-재윤님 방 조회하실 때, 특정 사용자의 방 내 참여자들에 대한 콕 찌르기 여부를 반환해주는 메서드이니 사용하시기 바랍니다. */ - public KnockNotificationStatusResponse checkMyKnockNotificationStatusInRoom(MemberTest member, Long roomId) { - List participants = participantSearchRepository.findOtherParticipantsInRoom(member.memberId(), - roomId); + public KnockNotificationStatusResponse checkMyKnockNotificationStatusInRoom(AuthorizationMember member, + Long roomId) { + List participants = participantSearchRepository.findOtherParticipantsInRoom(member.id(), roomId); Predicate knockPredicate = targetId -> - notificationRepository.existsByKey(generateKnockKey(member.memberId(), targetId, roomId)); + notificationRepository.existsByKey(generateKnockKey(member.id(), targetId, roomId)); Map> knockNotificationStatus = participants.stream() .map(Participant::getMemberId) diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index 4a5e0d85..a3bf5a8c 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -15,6 +15,8 @@ import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,14 +29,15 @@ public class CouponController { @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) - public void createCoupon(@Valid @RequestBody CreateCouponRequest request) { - couponService.createCoupon(1L, request); + public void createCoupon(@CurrentMember AuthorizationMember admin, + @Valid @RequestBody CreateCouponRequest request) { + couponService.createCoupon(admin, request); } @DeleteMapping("/admins/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public void deleteCoupon(@PathVariable Long couponId) { - couponService.deleteCoupon(1L, couponId); + public void deleteCoupon(@CurrentMember AuthorizationMember admin, @PathVariable Long couponId) { + couponService.deleteCoupon(admin, couponId); } @GetMapping("/coupons/{couponId}") diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java index a02a1061..4c792c17 100644 --- a/src/main/java/com/moabam/api/presentation/NotificationController.java +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -6,7 +6,8 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.notification.NotificationService; -import com.moabam.global.auth.annotation.MemberTest; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; import lombok.RequiredArgsConstructor; @@ -18,7 +19,8 @@ public class NotificationController { private final NotificationService notificationService; @GetMapping("/rooms/{roomId}/members/{memberId}") - public void sendKnockNotification(@PathVariable Long roomId, @PathVariable Long memberId) { - notificationService.sendKnockNotification(new MemberTest(1L, "nickname"), memberId, roomId); + public void sendKnockNotification(@CurrentMember AuthorizationMember member, @PathVariable Long roomId, + @PathVariable Long memberId) { + notificationService.sendKnockNotification(member, memberId, roomId); } } diff --git a/src/main/java/com/moabam/global/auth/annotation/MemberTest.java b/src/main/java/com/moabam/global/auth/annotation/MemberTest.java deleted file mode 100644 index a426856d..00000000 --- a/src/main/java/com/moabam/global/auth/annotation/MemberTest.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.moabam.global.auth.annotation; - -public record MemberTest( - Long memberId, - String nickname -) { - -} diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/CouponServiceTest.java index b234e420..f24639aa 100644 --- a/src/test/java/com/moabam/api/application/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/CouponServiceTest.java @@ -21,16 +21,21 @@ import com.moabam.api.domain.coupon.CouponType; import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; +import com.moabam.global.auth.model.AuthorizationMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; 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.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; import com.moabam.support.fixture.CouponFixture; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class CouponServiceTest { @InjectMocks @@ -42,86 +47,126 @@ class CouponServiceTest { @Mock private CouponSearchRepository couponSearchRepository; + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test void couponService_createCoupon() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); String couponType = CouponType.GOLDEN_COUPON.getTypeName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); given(couponRepository.existsByName(any(String.class))).willReturn(false); // When - couponService.createCoupon(1L, request); + couponService.createCoupon(admin, request); // Then verify(couponRepository).save(any(Coupon.class)); } + @WithMember(role = Role.USER) + @DisplayName("권한 없는 사용자가 쿠폰을 발행한다. - NotFoundException") + @Test + void couponService_createCoupon_Admin_NotFoundException() { + // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); + String couponType = CouponType.GOLDEN_COUPON.getTypeName(); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + + // When & Then + assertThatThrownBy(() -> couponService.createCoupon(admin, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @WithMember(role = Role.ADMIN) @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") @Test void couponService_createCoupon_ConflictException() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); String couponType = CouponType.GOLDEN_COUPON.getTypeName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); given(couponRepository.existsByName(any(String.class))).willReturn(true); // When & Then - assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + assertThatThrownBy(() -> couponService.createCoupon(admin, request)) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_COUPON_NAME.getMessage()); } + @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") @Test void couponService_createCoupon_NotFoundException() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 1, 2); given(couponRepository.existsByName(any(String.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + assertThatThrownBy(() -> couponService.createCoupon(admin, request)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); } + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 발급 종료 기간이 시작 기간보다 더 이전인 쿠폰을 발행한다. - BadRequestException") @Test void couponService_createCoupon_BadRequestException() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); String couponType = CouponType.GOLDEN_COUPON.getTypeName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 1); given(couponRepository.existsByName(any(String.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> couponService.createCoupon(1L, request)) + assertThatThrownBy(() -> couponService.createCoupon(admin, request)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 삭제한다. - Void") @Test void couponService_deleteCoupon() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); Coupon coupon = CouponFixture.coupon(10, 100); given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); // When - couponService.deleteCoupon(1L, 1L); + couponService.deleteCoupon(admin, 1L); // Then verify(couponRepository).delete(coupon); } + @WithMember(role = Role.USER) + @DisplayName("권한 없는 사용자가 쿠폰을 삭제한다. - NotFoundException") + @Test + void couponService_deleteCoupon_Admin_NotFoundException() { + // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); + + // When & Then + assertThatThrownBy(() -> couponService.deleteCoupon(admin, 1L)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } + + @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰 아이디를 삭제하려고 시도한다. - NotFoundException") @Test void couponService_deleteCoupon_NotFoundException() { // Given + AuthorizationMember admin = AuthorizationThreadLocal.getAuthorizationMember(); given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> couponService.deleteCoupon(1L, 1L)) + assertThatThrownBy(() -> couponService.deleteCoupon(admin, 1L)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); } diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/NotificationServiceTest.java index 31eac5d9..6a093278 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/NotificationServiceTest.java @@ -5,7 +5,6 @@ import java.util.List; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -23,12 +22,15 @@ import com.moabam.api.domain.room.repository.ParticipantSearchRepository; import com.moabam.api.dto.notification.KnockNotificationStatusResponse; import com.moabam.api.infrastructure.redis.NotificationRepository; -import com.moabam.global.auth.annotation.MemberTest; +import com.moabam.global.auth.model.AuthorizationMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; 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; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class NotificationServiceTest { @InjectMocks @@ -46,64 +48,68 @@ class NotificationServiceTest { @Mock private ParticipantSearchRepository participantSearchRepository; - private MemberTest memberTest; - - @BeforeEach - void setUp() { - memberTest = new MemberTest(2L, "nickname"); - } - + @WithMember @DisplayName("성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test void notificationService_sendKnockNotification() { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); // When - notificationService.sendKnockNotification(memberTest, 2L, 1L); + notificationService.sendKnockNotification(member, 2L, 1L); // Then verify(firebaseMessaging).sendAsync(any(Message.class)); verify(notificationRepository).saveKnockNotification(any(String.class)); } + @WithMember @DisplayName("콕 찌를 상대의 방이 존재하지 않는다. - NotFoundException") @Test void notificationService_sendKnockNotification_Room_NotFoundException() { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); // When & Then - assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnockNotification(member, 1L, 1L)) .isInstanceOf(NotFoundException.class); } + @WithMember @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") @Test void notificationService_sendKnockNotification_FcmToken_NotFoundException() { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsByKey(any(String.class))).willReturn(false); given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnockNotification(member, 1L, 1L)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); } + @WithMember @DisplayName("콕 찌를 상대가 이미 찌른 상대이다. - ConflictException") @Test void notificationService_sendKnockNotification_ConflictException() { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsByKey(any(String.class))).willReturn(true); // When & Then - assertThatThrownBy(() -> notificationService.sendKnockNotification(memberTest, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnockNotification(member, 1L, 1L)) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } @@ -123,6 +129,7 @@ void notificationService_sendCertificationTimeNotification(List par verify(firebaseMessaging, times(3)).sendAsync(any(Message.class)); } + @WithMember @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest @@ -138,36 +145,41 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); } + @WithMember @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸다. - KnockNotificationStatusResponse") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + given(participantSearchRepository.findOtherParticipantsInRoom(any(Long.class), any(Long.class))) .willReturn(participants); given(notificationRepository.existsByKey(any(String.class))).willReturn(true); // When KnockNotificationStatusResponse actual = - notificationService.checkMyKnockNotificationStatusInRoom(memberTest, 1L); + notificationService.checkMyKnockNotificationStatusInRoom(member, 1L); // Then assertThat(actual.knockedMembersId()).hasSize(3); assertThat(actual.notKnockedMembersId()).isEmpty(); } + @WithMember @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸 적이 없다. - KnockNotificationStatusResponse") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest void notificationService_notKnocked_checkMyKnockNotificationStatusInRoom(List participants) { // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); given(participantSearchRepository.findOtherParticipantsInRoom(any(Long.class), any(Long.class))) .willReturn(participants); given(notificationRepository.existsByKey(any(String.class))).willReturn(false); // When KnockNotificationStatusResponse actual = - notificationService.checkMyKnockNotificationStatusInRoom(memberTest, 1L); + notificationService.checkMyKnockNotificationStatusInRoom(member, 1L); // Then assertThat(actual.knockedMembersId()).isEmpty(); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index fb170675..834c2f1b 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -26,9 +26,11 @@ import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.CouponType; import com.moabam.api.domain.coupon.repository.CouponRepository; +import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; 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.CouponSnippetFixture; @@ -49,6 +51,7 @@ class CouponControllerTest extends WithoutFilterSupporter { @Autowired private CouponRepository couponRepository; + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test void couponController_createCoupon() throws Exception { @@ -68,6 +71,7 @@ void couponController_createCoupon() throws Exception { .andExpect(status().isCreated()); } + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 발급 종료기간 시작기간보다 이전인 쿠폰을 발행한다. - BadRequestException") @Test void couponController_createCoupon_BadRequestException() throws Exception { @@ -90,6 +94,7 @@ void couponController_createCoupon_BadRequestException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") @Test void couponController_createCoupon_ConflictException() throws Exception { @@ -113,6 +118,7 @@ void couponController_createCoupon_ConflictException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_NAME.getMessage())); } + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 삭제한다. - Void") @Test void couponController_deleteCoupon() throws Exception { @@ -128,6 +134,7 @@ void couponController_deleteCoupon() throws Exception { .andExpect(status().isOk()); } + @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰을 삭제한다. - NotFoundException") @Test void couponController_deleteCoupon_NotFoundException() throws Exception { diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 2470e4fb..2b2f8bda 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -30,6 +30,7 @@ import com.moabam.api.infrastructure.redis.NotificationRepository; import com.moabam.api.infrastructure.redis.StringRedisRepository; import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.ErrorSnippetFixture; import com.moabam.support.fixture.MemberFixture; @@ -80,6 +81,7 @@ void setDown() { stringRedisRepository.delete(knockKey); } + @WithMember @DisplayName("GET - 성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test void notificationController_sendKnockNotification() throws Exception { @@ -95,6 +97,7 @@ void notificationController_sendKnockNotification() throws Exception { .andExpect(status().isOk()); } + @WithMember @DisplayName("GET - 콕 알림을 보낸 상대가 접속 중이 아니다. - NotFoundException") @Test void notificationController_sendKnockNotification_NotFoundException() throws Exception { @@ -110,6 +113,7 @@ void notificationController_sendKnockNotification_NotFoundException() throws Exc .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage())); } + @WithMember @DisplayName("GET - 이미 콕 알림을 보낸 대상이다. - ConflictException") @Test void notificationController_sendKnockNotification_ConflictException() throws Exception { From e6d7a7598609d61caeb6f9484fe1c4f3031e9909 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 17:15:56 +0900 Subject: [PATCH 041/185] =?UTF-8?q?fix:=20=EC=9D=B8=ED=94=84=EB=9D=BC=20ci?= =?UTF-8?q?/cd=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: nginx conf 수정 및 분리 * feat: 쉘 스크립트 파일 추가 * feat: docker-compose nginx volume 수정 * feat: ci, cd 파일 수정 * feat: dev 서버 프론트 * chore: config 업데이트 * chore: code smell 제거 --- .github/workflows/ci.yml | 6 +- .github/workflows/develop-cd.yml | 22 +-- docker-compose-dev.yml | 141 +++++++++--------- nginx/conf.d/header.conf | 9 ++ nginx/mime.types | 96 ++++++++++++ nginx/nginx.conf | 27 ++++ nginx/nginx.template | 60 -------- nginx/templates/http-server.template | 13 ++ nginx/templates/ssl-server.template | 13 ++ nginx/templates/upstream.template | 4 + scripts/deploy-dev.sh | 5 +- scripts/init-nginx-converter.sh | 5 +- .../com/moabam/global/config/WebConfig.java | 7 +- src/main/resources/config | 2 +- src/test/resources/application.yml | 2 + 15 files changed, 252 insertions(+), 160 deletions(-) create mode 100644 nginx/conf.d/header.conf create mode 100644 nginx/mime.types create mode 100644 nginx/nginx.conf delete mode 100644 nginx/nginx.template create mode 100644 nginx/templates/http-server.template create mode 100644 nginx/templates/ssl-server.template create mode 100644 nginx/templates/upstream.template diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d763e7bd..bd5beea4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: environment 세팅 run: | - echo "${{secrets.DEV_ENV_FILE }}" > ./.env + echo src/main/resources/config/dev.env > ./.env - name: Gradle 캐싱 uses: actions/cache@v3 @@ -38,10 +38,6 @@ jobs: - name: Gradle Grant 권한 부여 run: chmod +x gradlew - - name: 테스트용 MySQL 도커 컨테이너 실행 - run: | - sudo docker run -d -p 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 - - name: SonarCloud 캐싱 uses: actions/cache@v3 with: diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 08c90a3b..5953ac31 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -40,11 +40,12 @@ jobs: username: ubuntu key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} script: | - mkdir -p /home/ubuntu/moabam/nginx + mkdir -p /home/ubuntu/moabam/nginx/conf.d + mkdir -p /home/ubuntu/moabam/nginx/templates - name: Docker env 파일 생성 run: - echo "${{secrets.DEV_ENV_FILE }}" > ./.env + echo src/main/resources/config/dev.env > ./.env - name: 서버로 전송 기본 파일들 전송 uses: appleboy/scp-action@master @@ -53,17 +54,7 @@ jobs: port: 22 username: ${{ secrets.EC2_INSTANCE_USERNAME }} key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, init-letsencrypt.sh, ./scripts/*" - target: "/home/ubuntu/moabam" - - - name: 서버로 전송 "nginx conf 파일들" - uses: appleboy/scp-action@master - with: - host: ${{ secrets.EC2_INSTANCE_HOST }} - port: 22 - username: ${{ secrets.EC2_INSTANCE_USERNAME }} - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./nginx/*" + source: "./.env, ./docker-compose-dev.yml, ./scripts/* ./nginx/*" target: "/home/ubuntu/moabam" - name: 파일 세팅 @@ -113,10 +104,6 @@ jobs: - name: Gradle Grant 권한 부여 run: chmod +x gradlew - - name: 테스트용 MySQL 도커 컨테이너 실행 - run: | - sudo docker run -d -p 3306:3306 --env MYSQL_DATABASE=test --env MYSQL_ROOT_PASSWORD=test mysql:8.0.33 - - name: Gradle 빌드 uses: gradle/gradle-build-action@bd5760595778326ba7f1441bcf7e88b49de61a25 # v2.6.0 with: @@ -171,7 +158,6 @@ jobs: script: | cd /home/ubuntu/moabam echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin - sudo docker pull ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} ./scripts/deploy-dev.sh docker rm `docker ps -a -q` docker rmi $(docker images -aq) diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 4e92e97b..4d1e85a0 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -1,73 +1,74 @@ version: '3.7' services: - nginx: - image: nginx:latest - container_name: nginx - platform: linux/arm64/v8 - restart: always - ports: - - "80:80" - - "443:443" - volumes: - - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt - - /home/ubuntu/moabam/nginx/certbot/www:/var/www/certbot - - /home/ubuntu/moabam/nginx/nginx.conf:/etc/nginx/nginx.conf - command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'" - certbot: - image: certbot/certbot:latest - container_name: certbot - platform: linux/arm64 - restart: unless-stopped - volumes: - - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt - - /home/ubuntu/moabam/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: always - 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} - 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: - - /home/ubuntu/moabam/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 - volumes: - - /home/ubuntu/moabam/data/mysql:/var/lib/mysql + nginx: + image: nginx:latest + container_name: nginx + platform: linux/arm64/v8 + restart: always + ports: + - "80:80" + - "443:443" + volumes: + - /home/ubuntu/moabam/nginx/nginx.conf:/etc/nginx/nginx.conf + - /home/ubuntu/moabam/nginx/conf.d:/etc/nginx/conf.d + - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt + - /home/ubuntu/moabam/nginx/certbot/www:/var/www/certbot + certbot: + image: certbot/certbot:latest + container_name: certbot + platform: linux/arm64 + restart: unless-stopped + volumes: + - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt + - /home/ubuntu/moabam/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: + - /home/ubuntu/moabam/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 + volumes: + - /home/ubuntu/moabam/data/mysql:/var/lib/mysql diff --git a/nginx/conf.d/header.conf b/nginx/conf.d/header.conf new file mode 100644 index 00000000..0ffa54e9 --- /dev/null +++ b/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/nginx/mime.types b/nginx/mime.types new file mode 100644 index 00000000..7c7cdef2 --- /dev/null +++ b/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/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 00000000..5db2e1fb --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,27 @@ +worker_processes auto; + +events { + use epoll; + worker_connections 1024; +} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + + send_timeout 15s; + resolver_timeout 5s; + + server_tokens off; + + 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/nginx/nginx.template b/nginx/nginx.template deleted file mode 100644 index d62eef8a..00000000 --- a/nginx/nginx.template +++ /dev/null @@ -1,60 +0,0 @@ -worker_processes auto; - -events { - use epoll; - worker_connections 1024; -} - -http { - - include mime.types; - sendfile on; - - map $http_upgrade $connection_upgrade { - default "upgrade"; - } - - upstream backend { - server ${BLUE_CONTAINER}:${SERVER_PORT}; - keepalive 1024; - } - - server { - listen 80; - server_name ${SERVER_DOMAIN}; - server_tokens off; - - location / { - return 301 https://$host$request_uri; - } - - location /.well-known/acme-challenge/ { - allow all; - root /var/www/certbot; - } - } - - server { - listen 443 ssl; - server_name ${SERVER_DOMAIN}; - server_tokens off; - - 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 / { - resolver ${RESOLVER_IP} valid=10s; - proxy_pass http://backend; - proxy_http_version 1.1; - proxy_set_header Host $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/nginx/templates/http-server.template b/nginx/templates/http-server.template new file mode 100644 index 00000000..8b7a1f1e --- /dev/null +++ b/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/nginx/templates/ssl-server.template b/nginx/templates/ssl-server.template new file mode 100644 index 00000000..3ed5f615 --- /dev/null +++ b/nginx/templates/ssl-server.template @@ -0,0 +1,13 @@ +server { + listen 443 ssl; + server_name ${SERVER_DOMAIN}; + + 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/nginx/templates/upstream.template b/nginx/templates/upstream.template new file mode 100644 index 00000000..d32d6266 --- /dev/null +++ b/nginx/templates/upstream.template @@ -0,0 +1,4 @@ +upstream backend { + server ${BLUE_CONTAINER}:${SERVER_PORT}; + keepalive 1024; +} diff --git a/scripts/deploy-dev.sh b/scripts/deploy-dev.sh index 4786c149..d11b09c0 100644 --- a/scripts/deploy-dev.sh +++ b/scripts/deploy-dev.sh @@ -47,6 +47,7 @@ echo IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER}) NGINX_CONF="/home/ubuntu/moabam/nginx/nginx.conf" +UPSTREAM_CONF="/home/ubuntu/moabam/nginx/conf.d/upstream.conf" if [ -n "$IS_BLUE" ]; then echo "### BLUE => GREEN ###" @@ -62,7 +63,7 @@ if [ -n "$IS_BLUE" ]; then if [ -n "$REQUEST" ]; then echo "${GREEN_CONTAINER} health check 성공" - sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $NGINX_CONF + sed -i "s/${BLUE_CONTAINER}/${GREEN_CONTAINER}/g" $UPSTREAM_CONF echo "3. nginx 설정파일 reload" docker exec nginx service nginx reload echo "4. ${BLUE_CONTAINER} 컨테이너 종료" @@ -96,7 +97,7 @@ else if [ -n "$REQUEST" ]; then echo "${BLUE_CONTAINER} health check 성공" - sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $NGINX_CONF + sed -i "s/${GREEN_CONTAINER}/${BLUE_CONTAINER}/g" $UPSTREAM_CONF echo "3. nginx 설정파일 reload" docker exec nginx service nginx reload echo "4. ${GREEN_CONTAINER} 컨테이너 종료" diff --git a/scripts/init-nginx-converter.sh b/scripts/init-nginx-converter.sh index ad2b88e6..934f88c5 100644 --- a/scripts/init-nginx-converter.sh +++ b/scripts/init-nginx-converter.sh @@ -7,7 +7,8 @@ fi export SERVER_DOMAIN=${SERVER_DOMAIN} export SERVER_PORT=${SERVER_PORT} -export RESOLVER_IP=${RESOLVER_IP} export BLUE_CONTAINER=${BLUE_CONTAINER} -envsubst '$SERVER_DOMAIN $SERVER_PORT $RESOLVER_IP $BLUE_CONTAINER' < /home/ubuntu/moabam/nginx/nginx.template > /home/ubuntu/moabam/nginx/nginx.conf +envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/nginx/templates/http-server.template > /home/ubuntu/moabam/nginx/conf.d/http-server.conf +envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/nginx/templates/ssl-server.template > /home/ubuntu/moabam/nginx/conf.d/ssl-server.conf +envsubst '$BLUE_CONTAINER $SERVER_PORT' < /home/ubuntu/moabam/nginx/templates/upstream.template > /home/ubuntu/moabam/nginx/conf.d/upstream.conf diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index a7db79fb..5ca3103a 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -2,6 +2,7 @@ import java.util.List; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; @@ -17,12 +18,14 @@ public class WebConfig implements WebMvcConfigurer { private static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; private static final String ALLOW_ORIGIN_PATTERN = "[a-z]+\\.moabam.com"; - private static final String ALLOW_LOCAL_HOST = "http://localhost:3000"; + + @Value("${allow}") + private String allowLocalHost; @Override public void addCorsMappings(final CorsRegistry registry) { registry.addMapping("/**") - .allowedOriginPatterns(ALLOW_ORIGIN_PATTERN, ALLOW_LOCAL_HOST) + .allowedOriginPatterns(ALLOW_ORIGIN_PATTERN, allowLocalHost) .allowedMethods(ALLOWED_METHOD_NAMES.split(",")) .allowedHeaders("*") .allowCredentials(true) diff --git a/src/main/resources/config b/src/main/resources/config index 73b984ec..e92520b0 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 73b984ec52bfcc872a0acbe4b5a038dcc1d79262 +Subproject commit e92520b033468a9099145c215f8174d46174fa4e diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c140a299..6d2f0e0d 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -56,3 +56,5 @@ token: access-expire: 100000 refresh-expire: 150000 secret-key: testestestestestestestestestesttestestestestestestestestestest + +allow: "" From a6398516babfb5227a45805f0ab4737646574734 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 17:24:28 +0900 Subject: [PATCH 042/185] =?UTF-8?q?hotfix:=20env=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .github/workflows/develop-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bd5beea4..1d2022a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: environment 세팅 run: | - echo src/main/resources/config/dev.env > ./.env + cp src/main/resources/config/dev.env > ./.env - name: Gradle 캐싱 uses: actions/cache@v3 diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 5953ac31..b71d0279 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -45,7 +45,7 @@ jobs: - name: Docker env 파일 생성 run: - echo src/main/resources/config/dev.env > ./.env + cp src/main/resources/config/dev.env > ./.env - name: 서버로 전송 기본 파일들 전송 uses: appleboy/scp-action@master From b984dd0367b73faf80281250cf66da702401a48a Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 17:28:08 +0900 Subject: [PATCH 043/185] =?UTF-8?q?hotfix:=20env=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- .github/workflows/develop-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1d2022a9..e9e0f202 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: - name: environment 세팅 run: | - cp src/main/resources/config/dev.env > ./.env + cp src/main/resources/config/dev.env ./.env - name: Gradle 캐싱 uses: actions/cache@v3 diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index b71d0279..bb7b8d7a 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -45,7 +45,7 @@ jobs: - name: Docker env 파일 생성 run: - cp src/main/resources/config/dev.env > ./.env + cp src/main/resources/config/dev.env ./.env - name: 서버로 전송 기본 파일들 전송 uses: appleboy/scp-action@master From c1ce28be8b2072a8175abda6644dfd569154c070 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 17:35:39 +0900 Subject: [PATCH 044/185] =?UTF-8?q?hotfix:=20=ED=8C=8C=EC=9D=BC=20cd=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index bb7b8d7a..302a112a 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -40,8 +40,7 @@ jobs: username: ubuntu key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} script: | - mkdir -p /home/ubuntu/moabam/nginx/conf.d - mkdir -p /home/ubuntu/moabam/nginx/templates + mkdir -p /home/ubuntu/moabam/ - name: Docker env 파일 생성 run: @@ -54,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_INSTANCE_USERNAME }} key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, ./scripts/* ./nginx/*" + source: "./.env, ./docker-compose-dev.yml, ./scripts ./nginx" target: "/home/ubuntu/moabam" - name: 파일 세팅 From 2500f8cc2363264ede4e84c333d44572333211cd Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 17:39:42 +0900 Subject: [PATCH 045/185] =?UTF-8?q?hotfix:=20=ED=8C=8C=EC=9D=BC=20cd=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 302a112a..89a94528 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -53,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_INSTANCE_USERNAME }} key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, ./scripts ./nginx" + source: "./.env, ./docker-compose-dev.yml, ./scripts/*, ./nginx/*" target: "/home/ubuntu/moabam" - name: 파일 세팅 From 261f698202c826a4b1e4f33fec338ac6860c0ac5 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Tue, 14 Nov 2023 18:30:24 +0900 Subject: [PATCH 046/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=B6=94=EB=B0=A9?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방장 위임 기능 구현 * test: 방장 위임 기능 테스트 작성 * test: 방장이 아닌 유저의 요청인 경우 추가 * feat: participant deletedAt null일때 찾도록 추가 * feat: 방 추방 기능 구현 * test: 방 추방 통합 테스트 구현 * refactor: nginx conf 수정 * refactor: nginx conf 추가 수정 * refactor: nginx conf * chore: config 업데이트 --- nginx/nginx.conf | 12 ++++-- .../api/application/room/RoomService.java | 19 ++++++++++ .../ParticipantSearchRepository.java | 14 +++++-- .../api/presentation/RoomController.java | 6 +++ src/main/resources/config | 2 +- .../api/application/RoomServiceTest.java | 10 ++++- .../api/presentation/RoomControllerTest.java | 38 +++++++++++++++++++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/nginx/nginx.conf b/nginx/nginx.conf index 5db2e1fb..7b4e05a7 100644 --- a/nginx/nginx.conf +++ b/nginx/nginx.conf @@ -15,13 +15,17 @@ http { server_tokens off; - include ./conf.d/header.conf + 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; + include conf.d/upstream.conf; + include conf.d/http-server.conf; + include conf.d/ssl-server.conf; } diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 8b98b936..7a575ddc 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -52,6 +52,12 @@ public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { .memberId(memberId) .build(); + if (!isEnterRoomAvailable(memberId, room.getRoomType())) { + throw new BadRequestException(MEMBER_ROOM_EXCEED); + } + + increaseRoomCount(memberId, room.getRoomType()); + participant.enableManager(); Room savedRoom = roomRepository.save(room); routineRepository.saveAll(routines); @@ -126,6 +132,19 @@ public void mandateRoomManager(Long managerId, Long roomId, Long memberId) { memberParticipant.enableManager(); } + @Transactional + public void deportParticipant(Long managerId, Long roomId, Long memberId) { + Participant managerParticipant = getParticipant(managerId, roomId); + Participant memberParticipant = getParticipant(memberId, roomId); + Room room = managerParticipant.getRoom(); + + validateManagerAuthorization(managerParticipant); + + participantRepository.delete(memberParticipant); + room.decreaseCurrentUserCount(); + decreaseRoomCount(memberId, room.getRoomType()); + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); 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 index 25ef10dc..a00f7ea4 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -27,7 +27,8 @@ public Optional findOne(Long memberId, Long roomId) { .join(participant.room, room).fetchJoin() .where( DynamicQuery.generateEq(roomId, participant.room.id::eq), - DynamicQuery.generateEq(memberId, participant.memberId::eq) + DynamicQuery.generateEq(memberId, participant.memberId::eq), + participant.deletedAt.isNull() ) .fetchOne() ); @@ -37,7 +38,8 @@ public List findParticipants(Long roomId) { return jpaQueryFactory .selectFrom(participant) .where( - participant.room.id.eq(roomId) + participant.room.id.eq(roomId), + participant.deletedAt.isNull() ) .fetch(); } @@ -47,7 +49,8 @@ public List findOtherParticipantsInRoom(Long memberId, Long roomId) .selectFrom(participant) .where( participant.room.id.eq(roomId), - participant.memberId.ne(memberId) + participant.memberId.ne(memberId), + participant.deletedAt.isNull() ) .fetch(); } @@ -56,7 +59,10 @@ public List findAllByRoomCertifyTime(int certifyTime) { return jpaQueryFactory .selectFrom(participant) .join(participant.room, room).fetchJoin() - .where(participant.room.certifyTime.eq(certifyTime)) + .where( + participant.room.certifyTime.eq(certifyTime), + participant.deletedAt.isNull() + ) .fetch(); } } diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 978d7c3f..8439dfc6 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -77,4 +77,10 @@ public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List Date: Tue, 14 Nov 2023 18:35:23 +0900 Subject: [PATCH 047/185] =?UTF-8?q?refactor:=20=EC=95=84=EC=9D=B4=ED=85=9C?= =?UTF-8?q?/=EC=83=81=ED=92=88=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20=EB=B0=8F=20@CurrentMember=20=EC=A0=81=EC=9A=A9=20?= =?UTF-8?q?=20(#87)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 아이템 컨트롤러에 @CurrentMember 적용 * refactor: 아이템 컨트롤러 통합 테스트로 변경 * refactor: 상품 컨트롤러 통합 테스트로 변경 * test: 성공 테스트 추가 --- .../api/presentation/ItemController.java | 16 +- .../api/presentation/ItemControllerTest.java | 143 +++++++++++++----- .../presentation/ProductControllerTest.java | 24 +-- 3 files changed, 124 insertions(+), 59 deletions(-) diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java index 5c6b47ff..ed0d7d5a 100644 --- a/src/main/java/com/moabam/api/presentation/ItemController.java +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -14,6 +14,8 @@ 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.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,19 +29,21 @@ public class ItemController { @GetMapping @ResponseStatus(HttpStatus.OK) - public ItemsResponse getItems(@RequestParam ItemType type) { - return itemService.getItems(1L, type); + public ItemsResponse getItems(@CurrentMember AuthorizationMember member, @RequestParam ItemType type) { + return itemService.getItems(member.id(), type); } @PostMapping("/{itemId}/purchase") @ResponseStatus(HttpStatus.OK) - public void purchaseItem(@PathVariable Long itemId, @Valid @RequestBody PurchaseItemRequest request) { - itemService.purchaseItem(1L, itemId, request); + public void purchaseItem(@CurrentMember AuthorizationMember member, + @PathVariable Long itemId, + @Valid @RequestBody PurchaseItemRequest request) { + itemService.purchaseItem(member.id(), itemId, request); } @PostMapping("/{itemId}/select") @ResponseStatus(HttpStatus.OK) - public void selectItem(@PathVariable Long itemId) { - itemService.selectItem(1L, itemId); + public void selectItem(@CurrentMember AuthorizationMember member, @PathVariable Long itemId) { + itemService.selectItem(member.id(), itemId); } } diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 2ccbc7c4..78e367c4 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -1,8 +1,10 @@ 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 java.util.Collections.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; import static org.springframework.http.MediaType.*; @@ -13,24 +15,33 @@ 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.WebMvcTest; +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.item.ItemMapper; -import com.moabam.api.application.item.ItemService; +import com.moabam.api.application.member.MemberService; import com.moabam.api.domain.bug.BugType; 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; -@WebMvcTest(controllers = ItemController.class) +@Transactional +@SpringBootTest +@AutoConfigureMockMvc class ItemControllerTest extends WithoutFilterSupporter { @Autowired @@ -40,44 +51,93 @@ class ItemControllerTest extends WithoutFilterSupporter { ObjectMapper objectMapper; @MockBean - ItemService itemService; + MemberService memberService; + + @Autowired + ItemRepository itemRepository; + + @Autowired + InventoryRepository inventoryRepository; @DisplayName("아이템 목록을 조회한다.") - @WithMember - @Test - void get_items_success() throws Exception { - // given - Long memberId = 1L; - ItemType type = ItemType.MORNING; - Item item1 = morningSantaSkin().build(); - Item item2 = morningKillerSkin().build(); - ItemsResponse expected = ItemMapper.toItemsResponse(List.of(item1, item2), emptyList()); - given(itemService.getItems(memberId, type)).willReturn(expected); + @Nested + class GetItems { - // when, then - String content = mockMvc.perform( - get("/items").param("type", ItemType.MORNING.name()).contentType(APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()) - .andReturn() - .getResponse() - .getContentAsString(UTF_8); - ItemsResponse actual = objectMapper.readValue(content, ItemsResponse.class); - assertThat(actual).isEqualTo(expected); + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthorizationMember().id(); + Item item1 = itemRepository.save(morningSantaSkin().build()); + inventoryRepository.save(inventory(memberId, item1)); + Item item2 = itemRepository.save(morningKillerSkin().build()); + ItemsResponse expected = ItemMapper.toItemsResponse(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("아이템을 구매한다.") - @Test - void purchase_item_success() throws Exception { - // given - Long memberId = 1L; - Long itemId = 1L; - PurchaseItemRequest request = new PurchaseItemRequest(BugType.MORNING); + class PurchaseItem { - // when, then - mockMvc.perform(post("/items/{itemId}/purchase", itemId).contentType(APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))).andDo(print()).andExpect(status().isOk()); - verify(itemService).purchaseItem(memberId, itemId, request); + @DisplayName("성공한다.") + @WithMember + @Test + void success() throws Exception { + // given + Long memberId = getAuthorizationMember().id(); + Item item = itemRepository.save(nightMageSkin()); + PurchaseItemRequest request = new PurchaseItemRequest(BugType.NIGHT); + given(memberService.getById(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("아이템을 적용한다.") @@ -85,13 +145,14 @@ void purchase_item_success() throws Exception { @Test void select_item_success() throws Exception { // given - Long memberId = 1L; - Long itemId = 1L; + Long memberId = getAuthorizationMember().id(); + Item item = itemRepository.save(nightMageSkin()); + inventoryRepository.save(inventory(memberId, item)); // when, then - mockMvc.perform(post("/items/{itemId}/select", itemId).contentType(APPLICATION_JSON)) - .andDo(print()) - .andExpect(status().isOk()); - verify(itemService).selectItem(memberId, itemId); + 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/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java index cd6ef826..df4f0c0f 100644 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java @@ -3,7 +3,6 @@ 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.*; @@ -14,18 +13,21 @@ 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.web.servlet.WebMvcTest; -import org.springframework.boot.test.mock.mockito.MockBean; +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.application.product.ProductMapper; -import com.moabam.api.application.product.ProductService; import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; import com.moabam.api.dto.product.ProductsResponse; import com.moabam.support.common.WithoutFilterSupporter; -@WebMvcTest(controllers = ProductController.class) +@Transactional +@SpringBootTest +@AutoConfigureMockMvc class ProductControllerTest extends WithoutFilterSupporter { @Autowired @@ -34,23 +36,21 @@ class ProductControllerTest extends WithoutFilterSupporter { @Autowired ObjectMapper objectMapper; - @MockBean - ProductService productService; + @Autowired + ProductRepository productRepository; @DisplayName("상품 목록을 조회한다.") @Test void get_products_success() throws Exception { // given - Product product1 = bugProduct(); - Product product2 = bugProduct(); - ProductsResponse expected = ProductMapper.toProductsResponse(List.of(product1, product2)); - given(productService.getProducts()).willReturn(expected); + List products = productRepository.saveAll(List.of(bugProduct(), bugProduct())); + ProductsResponse expected = ProductMapper.toProductsResponse(products); // when, then String content = mockMvc.perform(get("/products") .contentType(APPLICATION_JSON)) - .andDo(print()) .andExpect(status().isOk()) + .andDo(print()) .andReturn() .getResponse() .getContentAsString(UTF_8); From 1d63d816c911ceab471e3c1afb4e2a8d56801c0a Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 14 Nov 2023 18:53:42 +0900 Subject: [PATCH 048/185] =?UTF-8?q?hotfix:=20kakao=20path=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/moabam/global/config/WebConfig.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index 5ca3103a..ec0bbd33 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -48,7 +48,13 @@ public PathResolver pathResolver() { .permitAll(List.of( PathMapper.parsePath("/"), PathMapper.parsePath("/members"), - PathMapper.parsePath("/members/login/*/oauth") + PathMapper.parsePath("/members/login/*/oauth"), + PathMapper.parsePath("/css/*"), + PathMapper.parsePath("/js/*"), + PathMapper.parsePath("/images/*"), + PathMapper.parsePath("/webjars/*"), + PathMapper.parsePath("/favicon/*"), + PathMapper.parsePath("/*/icon-*") )) .build(); From c28a03358e38c0cd2641bf5e706675468bba57c8 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Wed, 15 Nov 2023 14:24:03 +0900 Subject: [PATCH 049/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20@CurrentMember=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9,=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20(#89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방장 위임 기능 구현 * test: 방장 위임 기능 테스트 작성 * test: 방장이 아닌 유저의 요청인 경우 추가 * feat: participant deletedAt null일때 찾도록 추가 * feat: 방 추방 기능 구현 * test: 방 추방 통합 테스트 구현 * refactor: nginx conf 수정 * refactor: nginx conf 추가 수정 * refactor: BugSearchRepository 위치 변경 * refactor: RoomController @CurrentMember 적용 * refactor: 메서드명 변경 * refactor: 테스트 코드 리팩토링 * refactor: 테스트 패키지 구조 변경 --- .../api/application/bug/BugService.java | 2 +- .../application/room/RoomSearchService.java | 2 +- .../api/application/room/RoomService.java | 2 +- .../BugHistorySearchRepository.java | 2 +- .../repository/RoutineSearchRepository.java | 2 +- .../api/presentation/RoomController.java | 49 +++-- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../RoomCertificationServiceTest.java | 5 +- .../{ => room}/RoomServiceTest.java | 3 +- .../{entity => room}/CertificationTest.java | 7 +- .../api/domain/{entity => room}/RoomTest.java | 4 +- .../CertificationsSearchRepositoryTest.java | 9 +- .../ParticipantSearchRepositoryTest.java | 5 +- .../api/presentation/BugControllerTest.java | 2 +- .../api/presentation/RoomControllerTest.java | 171 ++++++++---------- .../moabam/support/fixture/RoomFixture.java | 4 +- 18 files changed, 126 insertions(+), 149 deletions(-) rename src/main/java/com/moabam/api/domain/{ => bug}/repository/BugHistorySearchRepository.java (96%) rename src/test/java/com/moabam/api/application/{ => room}/RoomCertificationServiceTest.java (97%) rename src/test/java/com/moabam/api/application/{ => room}/RoomServiceTest.java (98%) rename src/test/java/com/moabam/api/domain/{entity => room}/CertificationTest.java (80%) rename src/test/java/com/moabam/api/domain/{entity => room}/RoomTest.java (95%) rename src/test/java/com/moabam/api/domain/{ => room}/repository/CertificationsSearchRepositoryTest.java (86%) rename src/test/java/com/moabam/api/domain/{ => room}/repository/ParticipantSearchRepositoryTest.java (89%) diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index eea27cf9..76b10a9f 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -11,8 +11,8 @@ import com.moabam.api.application.member.MemberService; import com.moabam.api.domain.bug.BugHistory; import com.moabam.api.domain.bug.BugType; +import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; import com.moabam.api.domain.member.Member; -import com.moabam.api.domain.repository.BugHistorySearchRepository; import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; import com.moabam.global.common.util.ClockHolder; diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index ff6fb40e..7b063005 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -64,7 +64,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { } private List getRoutineResponses(Long roomId) { - List roomRoutines = routineSearchRepository.findByRoomId(roomId); + List roomRoutines = routineSearchRepository.findAllByRoomId(roomId); return RoutineMapper.toRoutineResponses(roomRoutines); } diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 7a575ddc..01ec189c 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -78,7 +78,7 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); - List routines = routineSearchRepository.findByRoomId(roomId); + List routines = routineSearchRepository.findAllByRoomId(roomId); routineRepository.deleteAll(routines); List newRoutines = RoutineMapper.toRoutineEntities(room, modifyRoomRequest.routines()); diff --git a/src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java similarity index 96% rename from src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java rename to src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java index 724289af..26a8f9ff 100644 --- a/src/main/java/com/moabam/api/domain/repository/BugHistorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.bug.repository; import static com.moabam.api.domain.bug.QBugHistory.*; diff --git a/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java index f2f11bb6..827e1b0d 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java @@ -17,7 +17,7 @@ public class RoutineSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public List findByRoomId(Long roomId) { + public List findAllByRoomId(Long roomId) { return jpaQueryFactory .selectFrom(routine) .where( diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 8439dfc6..314ac697 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -22,6 +22,8 @@ import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; import com.moabam.api.dto.room.RoomDetailsResponse; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -37,50 +39,63 @@ public class RoomController { @PostMapping @ResponseStatus(HttpStatus.CREATED) - public Long createRoom(@Valid @RequestBody CreateRoomRequest createRoomRequest) { - return roomService.createRoom(1L, createRoomRequest); + public Long createRoom(@CurrentMember AuthorizationMember authorizationMember, + @Valid @RequestBody CreateRoomRequest createRoomRequest) { + + return roomService.createRoom(authorizationMember.id(), createRoomRequest); } @PutMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public void modifyRoom(@Valid @RequestBody ModifyRoomRequest modifyRoomRequest, - @PathVariable("roomId") Long roomId) { - roomService.modifyRoom(1L, roomId, modifyRoomRequest); + public void modifyRoom(@CurrentMember AuthorizationMember authorizationMember, + @Valid @RequestBody ModifyRoomRequest modifyRoomRequest, @PathVariable("roomId") Long roomId) { + + roomService.modifyRoom(authorizationMember.id(), roomId, modifyRoomRequest); } @PostMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public void enterRoom(@PathVariable("roomId") Long roomId, @Valid @RequestBody EnterRoomRequest enterRoomRequest) { - roomService.enterRoom(1L, roomId, enterRoomRequest); + public void enterRoom(@CurrentMember AuthorizationMember authorizationMember, @PathVariable("roomId") Long roomId, + @Valid @RequestBody EnterRoomRequest enterRoomRequest) { + + roomService.enterRoom(authorizationMember.id(), roomId, enterRoomRequest); } @DeleteMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public void exitRoom(@PathVariable("roomId") Long roomId) { - roomService.exitRoom(1L, roomId); + public void exitRoom(@CurrentMember AuthorizationMember authorizationMember, @PathVariable("roomId") Long roomId) { + roomService.exitRoom(authorizationMember.id(), roomId); } @GetMapping("/{roomId}") @ResponseStatus(HttpStatus.OK) - public RoomDetailsResponse getRoomDetails(@PathVariable("roomId") Long roomId) { - return roomSearchService.getRoomDetails(1L, roomId); + public RoomDetailsResponse getRoomDetails(@CurrentMember AuthorizationMember authorizationMember, + @PathVariable("roomId") Long roomId) { + + return roomSearchService.getRoomDetails(authorizationMember.id(), roomId); } @PostMapping("/{roomId}/certification") @ResponseStatus(HttpStatus.CREATED) - public void certifyRoom(@PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { - roomCertificationService.certifyRoom(1L, roomId, multipartFiles); + public void certifyRoom(@CurrentMember AuthorizationMember authorizationMember, @PathVariable("roomId") Long roomId, + @RequestPart List multipartFiles) { + + roomCertificationService.certifyRoom(authorizationMember.id(), roomId, multipartFiles); } @PutMapping("/{roomId}/members/{memberId}/mandate") @ResponseStatus(HttpStatus.OK) - public void mandateManager(@PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { - roomService.mandateRoomManager(1L, roomId, memberId); + public void mandateManager(@CurrentMember AuthorizationMember authorizationMember, + @PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { + + roomService.mandateRoomManager(authorizationMember.id(), roomId, memberId); } @DeleteMapping("/{roomId}/members/{memberId}") @ResponseStatus(HttpStatus.OK) - public void deportParticipant(@PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { - roomService.deportParticipant(1L, roomId, memberId); + public void deportParticipant(@CurrentMember AuthorizationMember authorizationMember, + @PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { + + roomService.deportParticipant(authorizationMember.id(), roomId, memberId); } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index dc7b0eb3..406bcda8 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 3b245d88..8f28a0c8 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 0e9f868c..0673186a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java similarity index 97% rename from src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java rename to src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java index 67a5daa3..0e8fb114 100644 --- a/src/test/java/com/moabam/api/application/RoomCertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java @@ -1,7 +1,7 @@ -package com.moabam.api.application; +package com.moabam.api.application.room; import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.BDDMockito.*; import java.time.LocalDate; @@ -23,7 +23,6 @@ import com.moabam.api.application.image.ImageService; import com.moabam.api.application.member.MemberService; -import com.moabam.api.application.room.RoomCertificationService; import com.moabam.api.domain.image.ImageType; import com.moabam.api.domain.member.Member; import com.moabam.api.domain.room.DailyMemberCertification; diff --git a/src/test/java/com/moabam/api/application/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java similarity index 98% rename from src/test/java/com/moabam/api/application/RoomServiceTest.java rename to src/test/java/com/moabam/api/application/room/RoomServiceTest.java index 8b7112f5..d6f8089b 100644 --- a/src/test/java/com/moabam/api/application/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.application; +package com.moabam.api.application.room; import static com.moabam.api.domain.room.RoomType.*; import static org.assertj.core.api.Assertions.*; @@ -17,7 +17,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import com.moabam.api.application.member.MemberService; -import com.moabam.api.application.room.RoomService; import com.moabam.api.application.room.mapper.RoomMapper; import com.moabam.api.domain.member.Member; import com.moabam.api.domain.room.Participant; diff --git a/src/test/java/com/moabam/api/domain/entity/CertificationTest.java b/src/test/java/com/moabam/api/domain/room/CertificationTest.java similarity index 80% rename from src/test/java/com/moabam/api/domain/entity/CertificationTest.java rename to src/test/java/com/moabam/api/domain/room/CertificationTest.java index 1a7ac251..338bbc23 100644 --- a/src/test/java/com/moabam/api/domain/entity/CertificationTest.java +++ b/src/test/java/com/moabam/api/domain/room/CertificationTest.java @@ -1,15 +1,10 @@ -package com.moabam.api.domain.entity; +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 com.moabam.api.domain.room.Certification; -import com.moabam.api.domain.room.Room; -import com.moabam.api.domain.room.RoomType; -import com.moabam.api.domain.room.Routine; - class CertificationTest { String content = "물 마시기"; diff --git a/src/test/java/com/moabam/api/domain/entity/RoomTest.java b/src/test/java/com/moabam/api/domain/room/RoomTest.java similarity index 95% rename from src/test/java/com/moabam/api/domain/entity/RoomTest.java rename to src/test/java/com/moabam/api/domain/room/RoomTest.java index 236db83f..21889ac3 100644 --- a/src/test/java/com/moabam/api/domain/entity/RoomTest.java +++ b/src/test/java/com/moabam/api/domain/room/RoomTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.entity; +package com.moabam.api.domain.room; import static org.assertj.core.api.Assertions.*; @@ -7,8 +7,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import com.moabam.api.domain.room.Room; -import com.moabam.api.domain.room.RoomType; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java similarity index 86% rename from src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java rename to src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java index 361780be..27ca807a 100644 --- a/src/test/java/com/moabam/api/domain/repository/CertificationsSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/room/repository/CertificationsSearchRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.room.repository; import static org.assertj.core.api.Assertions.*; @@ -16,13 +16,6 @@ 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.RoomRepository; -import com.moabam.api.domain.room.repository.RoutineRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; import com.moabam.support.fixture.RoomFixture; diff --git a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java similarity index 89% rename from src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java rename to src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java index 2f703ca2..7df838fe 100644 --- a/src/test/java/com/moabam/api/domain/repository/ParticipantSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.room.repository; import static org.assertj.core.api.Assertions.*; @@ -13,9 +13,6 @@ 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.ParticipantSearchRepository; -import com.moabam.api.domain.room.repository.RoomRepository; import com.moabam.global.config.JpaConfig; @DataJpaTest diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index a27c9af6..6431727b 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -27,7 +27,7 @@ import com.moabam.api.application.bug.BugMapper; import com.moabam.api.application.member.MemberService; import com.moabam.api.domain.bug.repository.BugHistoryRepository; -import com.moabam.api.domain.repository.BugHistorySearchRepository; +import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; import com.moabam.support.annotation.WithMember; diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 18013047..7ba6206a 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -40,6 +40,7 @@ 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.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; @@ -67,6 +68,9 @@ class RoomControllerTest extends WithoutFilterSupporter { @Autowired private RoutineRepository routineRepository; + @Autowired + private RoutineSearchRepository routineSearchRepository; + @Autowired private ParticipantRepository participantRepository; @@ -112,6 +116,7 @@ void create_room_no_password_success() throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -129,6 +134,7 @@ void create_room_no_password_success() throws Exception { } @DisplayName("비밀번호 있는 방 생성 성공") + @WithMember @ParameterizedTest @CsvSource({ "1234", "12345678", "98765" @@ -138,6 +144,7 @@ void create_room_with_password_success(String password) throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 있는 재맹의 방임", password, routines, MORNING, 10, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -164,6 +171,7 @@ void create_room_with_wrong_password_fail(String password) throws Exception { List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 있는 재윤과 앵맹이의 방임", password, routines, MORNING, 10, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -187,6 +195,7 @@ void create_room_with_too_many_routine_fail() throws Exception { routines.add("코드 리뷰 달기"); routines.add("책 읽기"); routines.add("산책 하기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, 10, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -204,6 +213,7 @@ void create_room_with_too_many_routine_fail() throws Exception { 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); @@ -216,7 +226,7 @@ void create_room_with_no_routine_fail() throws Exception { .andDo(print()); } - @DisplayName("올바르지 못한 시간으로 아침 방 생성") + @DisplayName("올바르지 못한 시간으로 아침 방 생성시 예외 발생") @ParameterizedTest @CsvSource({ "1", "3", "11", "12", "20" @@ -226,6 +236,7 @@ void create_morning_room_wrong_certify_time_fail(int certifyTime) throws Excepti List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, MORNING, certifyTime, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -248,6 +259,7 @@ void create_night_room_wrong_certify_time_fail(int certifyTime) throws Exception List routines = new ArrayList<>(); routines.add("물 마시기"); routines.add("코테 풀기"); + CreateRoomRequest createRoomRequest = new CreateRoomRequest( "비번 없는 재윤과 앵맹이의 방임", null, routines, NIGHT, certifyTime, 4); String json = objectMapper.writeValueAsString(createRoomRequest); @@ -261,6 +273,7 @@ void create_night_room_wrong_certify_time_fail(int certifyTime) throws Exception } @DisplayName("방 수정 성공 - 방장일 경우") + @WithMember(id = 1L) @Test void modify_room_success() throws Exception { // given @@ -272,19 +285,20 @@ void modify_room_success() throws Exception { .maxUserCount(5) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + List routines = RoomFixture.routines(room); + + Participant participant = RoomFixture.participant(room, 1L); participant.enableManager(); - List routines = new ArrayList<>(); - routines.add("물 마시기"); - routines.add("코테 풀기"); + List newRoutines = new ArrayList<>(); + newRoutines.add("물 마시기"); + newRoutines.add("코테 풀기"); roomRepository.save(room); + routineRepository.saveAll(routines); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", null, routines, "1234", 10, 7); + + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "공지공지", newRoutines, "4567", 10, 7); String json = objectMapper.writeValueAsString(modifyRoomRequest); // expected @@ -293,9 +307,20 @@ void modify_room_success() throws Exception { .content(json)) .andExpect(status().isOk()) .andDo(print()); + + Room modifiedRoom = roomRepository.findById(room.getId()).orElseThrow(); + List modifiedRoutines = routineSearchRepository.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 @@ -307,10 +332,7 @@ void unauthorized_modify_room_fail() throws Exception { .maxUserCount(5) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + Participant participant = RoomFixture.participant(room, 1L); List routines = new ArrayList<>(); routines.add("물 마시기"); @@ -332,6 +354,7 @@ void unauthorized_modify_room_fail() throws Exception { } @DisplayName("비밀번호 있는 방 참여 성공") + @WithMember(id = 1L) @Test void enter_room_with_password_success() throws Exception { // given @@ -356,15 +379,11 @@ void enter_room_with_password_success() throws Exception { } @DisplayName("비밀번호 없는 방 참여 성공") + @WithMember(id = 1L) @Test void enter_room_with_no_password_success() throws Exception { // given - Room room = Room.builder() - .title("처음 제목") - .roomType(MORNING) - .certifyTime(9) - .maxUserCount(5) - .build(); + Room room = RoomFixture.room(); roomRepository.save(room); EnterRoomRequest enterRoomRequest = new EnterRoomRequest(null); @@ -379,6 +398,7 @@ void enter_room_with_no_password_success() throws Exception { } @DisplayName("방 참여 후 인원수 증가 테스트") + @WithMember(id = 1L) @Test void enter_and_increase_room_user_count() throws Exception { // given @@ -407,6 +427,7 @@ void enter_and_increase_room_user_count() throws Exception { } @DisplayName("아침 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @WithMember(id = 1L) @Test void enter_and_increase_morning_room_count() throws Exception { // given @@ -436,6 +457,7 @@ void enter_and_increase_morning_room_count() throws Exception { } @DisplayName("저녁 방 참여 후 사용자의 방 입장 횟수 증가 테스트") + @WithMember(id = 1L) @Test void enter_and_increase_night_room_count() throws Exception { // given @@ -521,6 +543,7 @@ void enter_and_night_room_over_three_fail() throws Exception { } @DisplayName("비밀번호 불일치 방 참여시 예외 발생") + @WithMember(id = 1L) @Test void enter_room_wrong_password_fail() throws Exception { // given @@ -555,6 +578,7 @@ void enter_room_wrong_password_fail() throws Exception { } @DisplayName("인원수가 모두 찬 방 참여시 예외 발생") + @WithMember(id = 1L) @Test void enter_max_user_room_fail() throws Exception { // given @@ -585,6 +609,7 @@ void enter_max_user_room_fail() throws Exception { } @DisplayName("일반 사용자의 방 나가기 성공") + @WithMember(id = 1L) @Test void no_manager_exit_room_success() throws Exception { // given @@ -594,10 +619,8 @@ void no_manager_exit_room_success() throws Exception { .certifyTime(21) .maxUserCount(8) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + + Participant participant = RoomFixture.participant(room, 1L); for (int i = 0; i < 4; i++) { room.increaseCurrentUserCount(); @@ -614,11 +637,13 @@ void no_manager_exit_room_success() throws Exception { participantRepository.flush(); Room findRoom = roomRepository.findById(room.getId()).orElseThrow(); Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + assertThat(findRoom.getCurrentUserCount()).isEqualTo(4); assertThat(deletedParticipant.getDeletedAt()).isNotNull(); } @DisplayName("방장의 방 나가기 - 방 삭제 성공") + @WithMember(id = 1L) @Test void manager_delete_room_success() throws Exception { // given @@ -628,11 +653,10 @@ void manager_delete_room_success() throws Exception { .certifyTime(21) .maxUserCount(8) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + + Participant participant = RoomFixture.participant(room, 1L); participant.enableManager(); + roomRepository.save(room); participantRepository.save(participant); @@ -642,11 +666,13 @@ void manager_delete_room_success() throws Exception { .andDo(print()); Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + assertThat(roomRepository.findById(room.getId())).isEmpty(); assertThat(deletedParticipant.getDeletedAt()).isNotNull(); } @DisplayName("방장이 위임하지 않고 방 나가기 실패") + @WithMember(id = 1L) @Test void manager_exit_room_fail() throws Exception { // given @@ -656,10 +682,8 @@ void manager_exit_room_fail() throws Exception { .certifyTime(21) .maxUserCount(10) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + + Participant participant = RoomFixture.participant(room, 1L); participant.enableManager(); for (int i = 0; i < 6; i++) { @@ -678,21 +702,13 @@ void manager_exit_room_fail() throws Exception { } @DisplayName("아침 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @WithMember(id = 1L) @Test void exit_and_decrease_morning_room_count() throws Exception { // given - Room room = Room.builder() - .title("방 제목") - .password("1234") - .roomType(MORNING) - .certifyTime(9) - .maxUserCount(5) - .build(); + Room room = RoomFixture.room(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + Participant participant = RoomFixture.participant(room, 1L); for (int i = 0; i < 3; i++) { member.enterMorningRoom(); @@ -713,6 +729,7 @@ void exit_and_decrease_morning_room_count() throws Exception { } @DisplayName("저녁 방 나가기 이후 사용자의 방 입장 횟수 감소 테스트") + @WithMember(id = 1L) @Test void exit_and_decrease_night_room_count() throws Exception { // given @@ -724,10 +741,7 @@ void exit_and_decrease_night_room_count() throws Exception { .maxUserCount(5) .build(); - Participant participant = Participant.builder() - .room(room) - .memberId(1L) - .build(); + Participant participant = RoomFixture.participant(room, 1L); for (int i = 0; i < 3; i++) { member.enterNightRoom(); @@ -748,6 +762,7 @@ void exit_and_decrease_night_room_count() throws Exception { } @DisplayName("방 상세 정보 조회 성공 테스트") + @WithMember(id = 1L) @Test void get_room_details_test() throws Exception { // given @@ -762,62 +777,34 @@ void get_room_details_test() throws Exception { room.increaseCurrentUserCount(); room.increaseCurrentUserCount(); - Routine routine1 = Routine.builder() - .room(room) - .content("물 마시기") - .build(); + List routines = RoomFixture.routines(room); - Routine routine2 = Routine.builder() - .room(room) - .content("코테 풀기") - .build(); - - Participant participant1 = Participant.builder() - .room(room) - .memberId(1L) - .build(); + Participant participant1 = RoomFixture.participant(room, 1L); participant1.enableManager(); - Member member2 = Member.builder() - .socialId(2L) - .nickname("NICKNAME_2") - .bug(BugFixture.bug()) - .build(); - - Member member3 = Member.builder() - .socialId(3L) - .nickname("NICKNAME_3") - .bug(BugFixture.bug()) - .build(); + Member member2 = MemberFixture.member(2L, "NICK2"); + Member member3 = MemberFixture.member(3L, "NICK3"); roomRepository.save(room); - routineRepository.save(routine1); - routineRepository.save(routine2); + routineRepository.saveAll(routines); memberRepository.save(member2); memberRepository.save(member3); - Participant participant2 = Participant.builder() - .room(room) - .memberId(member2.getId()) - .build(); - - Participant participant3 = Participant.builder() - .room(room) - .memberId(member3.getId()) - .build(); + 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(routine1) + .routine(routines.get(0)) .memberId(member.getId()) .image("member1Image") .build(); Certification certification2 = Certification.builder() - .routine(routine2) + .routine(routines.get(1)) .memberId(member.getId()) .image("member2Image") .build(); @@ -825,19 +812,12 @@ void get_room_details_test() throws Exception { certificationRepository.save(certification1); certificationRepository.save(certification2); - DailyMemberCertification dailyMemberCertification = DailyMemberCertification.builder() - .memberId(member.getId()) - .roomId(room.getId()) - .participant(participant1) - .build(); - + DailyMemberCertification dailyMemberCertification = RoomFixture.dailyMemberCertification(member.getId(), + room.getId(), participant1); dailyMemberCertificationRepository.save(dailyMemberCertification); - DailyRoomCertification dailyRoomCertification = DailyRoomCertification.builder() - .roomId(room.getId()) - .certifiedAt(LocalDate.now()) - .build(); - + DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(room.getId(), + LocalDate.now()); dailyRoomCertificationRepository.save(dailyRoomCertification); // expected @@ -847,6 +827,7 @@ void get_room_details_test() throws Exception { } @DisplayName("방 추방 성공") + @WithMember(id = 1L) @Test void deport_member_success() throws Exception { // given @@ -875,6 +856,6 @@ void deport_member_success() throws Exception { assertThat(getRoom.getCurrentUserCount()).isEqualTo(1); assertThat(getMemberParticipant.getDeletedAt()).isNotNull(); - assertThat(participantSearchRepository.findOne(3L, room.getId())).isEmpty(); + assertThat(participantSearchRepository.findOne(member.getId(), room.getId())).isEmpty(); } } diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java index 4a436205..088429e2 100644 --- a/src/test/java/com/moabam/support/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -49,11 +49,11 @@ public static List routines(Room room) { Routine routine1 = Routine.builder() .room(room) - .content("물 마시기") + .content("첫 루틴") .build(); Routine routine2 = Routine.builder() .room(room) - .content("코테 풀기") + .content("두번째 루틴") .build(); routines.add(routine1); From 7da9d5a4f3220d96ace3082a1d2bbd3ccddf333c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Wed, 15 Nov 2023 15:03:11 +0900 Subject: [PATCH 050/185] =?UTF-8?q?style:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EC=95=8C=EB=A6=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B6=84=EB=A6=AC=20(#90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- src/main/resources/static/docs/notification.html | 2 +- .../api/application/{ => coupon}/CouponServiceTest.java | 3 +-- .../{ => notification}/NotificationServiceTest.java | 3 +-- .../com/moabam/api/domain/{entity => coupon}/CouponTest.java | 4 +--- .../api/domain/{entity/enums => coupon}/CouponTypeTest.java | 3 +-- .../{ => coupon}/repository/CouponSearchRepositoryTest.java | 4 +--- .../moabam/api/dto/{ => coupon}/CreateCouponRequestTest.java | 3 +-- .../redis}/NotificationRepositoryTest.java | 5 +---- .../infrastructure/redis}/StringRedisRepositoryTest.java | 3 +-- 12 files changed, 12 insertions(+), 24 deletions(-) rename src/test/java/com/moabam/api/application/{ => coupon}/CouponServiceTest.java (98%) rename src/test/java/com/moabam/api/application/{ => notification}/NotificationServiceTest.java (98%) rename src/test/java/com/moabam/api/domain/{entity => coupon}/CouponTest.java (93%) rename src/test/java/com/moabam/api/domain/{entity/enums => coupon}/CouponTypeTest.java (89%) rename src/test/java/com/moabam/api/domain/{ => coupon}/repository/CouponSearchRepositoryTest.java (97%) rename src/test/java/com/moabam/api/dto/{ => coupon}/CreateCouponRequestTest.java (91%) rename src/test/java/com/moabam/api/{domain/repository => infrastructure/redis}/NotificationRepositoryTest.java (96%) rename src/test/java/com/moabam/{global/common/repository => api/infrastructure/redis}/StringRedisRepositoryTest.java (94%) diff --git a/src/main/resources/config b/src/main/resources/config index d41f1946..90cfb53f 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit d41f1946d93203b1ab2062f309ec5b82312ee96d +Subproject commit 90cfb53fec7a953ca50b8727cc1c6770034b85f3 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 406bcda8..47b45ffe 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 8f28a0c8..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 0673186a..206bb94a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java similarity index 98% rename from src/test/java/com/moabam/api/application/CouponServiceTest.java rename to src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index f24639aa..00e2f444 100644 --- a/src/test/java/com/moabam/api/application/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.application; +package com.moabam.api.application.coupon; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -16,7 +16,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.application.coupon.CouponService; import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.CouponType; import com.moabam.api.domain.coupon.repository.CouponRepository; diff --git a/src/test/java/com/moabam/api/application/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java similarity index 98% rename from src/test/java/com/moabam/api/application/NotificationServiceTest.java rename to src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 6a093278..f395e6c8 100644 --- a/src/test/java/com/moabam/api/application/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.application; +package com.moabam.api.application.notification; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -16,7 +16,6 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; -import com.moabam.api.application.notification.NotificationService; import com.moabam.api.application.room.RoomService; import com.moabam.api.domain.room.Participant; import com.moabam.api.domain.room.repository.ParticipantSearchRepository; diff --git a/src/test/java/com/moabam/api/domain/entity/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java similarity index 93% rename from src/test/java/com/moabam/api/domain/entity/CouponTest.java rename to src/test/java/com/moabam/api/domain/coupon/CouponTest.java index 0e38a1e8..9b11c988 100644 --- a/src/test/java/com/moabam/api/domain/entity/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.entity; +package com.moabam.api.domain.coupon; import static org.assertj.core.api.Assertions.*; @@ -7,8 +7,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.coupon.CouponType; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.CouponFixture; diff --git a/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java similarity index 89% rename from src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java rename to src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java index e80c5a61..64759cbb 100644 --- a/src/test/java/com/moabam/api/domain/entity/enums/CouponTypeTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java @@ -1,11 +1,10 @@ -package com.moabam.api.domain.entity.enums; +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.coupon.CouponType; import com.moabam.global.error.exception.NotFoundException; import com.moabam.global.error.model.ErrorMessage; diff --git a/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java similarity index 97% rename from src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java rename to src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java index 0f59a566..3bc94ec1 100644 --- a/src/test/java/com/moabam/api/domain/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.coupon.repository; import static org.assertj.core.api.Assertions.*; @@ -15,8 +15,6 @@ import org.springframework.context.annotation.Import; import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.coupon.repository.CouponRepository; -import com.moabam.api.domain.coupon.repository.CouponSearchRepository; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.global.config.JpaConfig; import com.moabam.global.error.exception.NotFoundException; diff --git a/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java similarity index 91% rename from src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java rename to src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java index 279b30df..a3fb6433 100644 --- a/src/test/java/com/moabam/api/dto/CreateCouponRequestTest.java +++ b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.dto; +package com.moabam.api.dto.coupon; import static org.assertj.core.api.Assertions.*; @@ -10,7 +10,6 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import com.moabam.api.dto.coupon.CreateCouponRequest; class CreateCouponRequestTest { diff --git a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java similarity index 96% rename from src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java rename to src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java index 0ca65307..ae30de5d 100644 --- a/src/test/java/com/moabam/api/domain/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.infrastructure.redis; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -12,9 +12,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.infrastructure.redis.NotificationRepository; -import com.moabam.api.infrastructure.redis.StringRedisRepository; - @ExtendWith(MockitoExtension.class) class NotificationRepositoryTest { diff --git a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java similarity index 94% rename from src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java rename to src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java index 5bdd86d1..ebd08268 100644 --- a/src/test/java/com/moabam/global/common/repository/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.global.common.repository; +package com.moabam.api.infrastructure.redis; import static org.assertj.core.api.Assertions.*; @@ -11,7 +11,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import com.moabam.api.infrastructure.redis.StringRedisRepository; import com.moabam.global.config.EmbeddedRedisConfig; import com.moabam.global.config.RedisConfig; From b7ca2493ffc73b73c54b3b68b1378b7261f21dcf Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 16 Nov 2023 02:15:27 +0900 Subject: [PATCH 051/185] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20redis=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20(#91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 토큰 redis 저장을 위한 dto 및 config 추가 * feat: webConfig 파일 추가 * feat: redis 토큰 저장 서비스 및 테스트 코드 추가 * feat: 에러시 모든 토큰 제거 추가 * refactor: config update * feat: config 추가 * refactor: code smell 제거 --- .../auth/AuthorizationService.java | 33 ++++++- .../application/auth/mapper/AuthMapper.java | 8 ++ .../moabam/api/dto/auth/TokenSaveValue.java | 11 +++ .../redis/HashTemplateRepository.java | 43 ++++++++++ .../infrastructure/redis/TokenRepository.java | 41 +++++++++ .../auth/filter/AuthorizationFilter.java | 16 ++++ .../global/common/util/CookieUtils.java | 6 ++ .../global/config/EmbeddedRedisConfig.java | 11 +++ .../com/moabam/global/config/RedisConfig.java | 11 +++ src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 12 +-- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 4 +- .../application/AuthorizationServiceTest.java | 47 +++++++++- .../repository/TokenRepostiroyTest.java | 86 +++++++++++++++++++ .../redis/HashTemplateRepositoryTest.java | 64 ++++++++++++++ .../filter/AuthorizationFilterTest.java | 39 +++++++++ .../fixture/TokenSaveValueFixture.java | 24 ++++++ 18 files changed, 445 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/auth/TokenSaveValue.java create mode 100644 src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java create mode 100644 src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java create mode 100644 src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java create mode 100644 src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java create mode 100644 src/test/java/com/moabam/support/fixture/TokenSaveValueFixture.java diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index ac130cc1..9ab86b2e 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -1,5 +1,7 @@ package com.moabam.api.application.auth; +import static java.util.Objects.*; + import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -7,6 +9,7 @@ import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; +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.dto.auth.AuthorizationCodeRequest; @@ -15,12 +18,15 @@ 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.redis.TokenRepository; 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.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.HttpServletResponse; @@ -35,6 +41,7 @@ public class AuthorizationService { private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; private final MemberService memberService; private final JwtProviderService jwtProviderService; + private final TokenRepository tokenRepository; public void redirectToLoginPage(HttpServletResponse httpServletResponse) { String authorizationCodeUri = getAuthorizationCodeUri(); @@ -121,13 +128,31 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq } public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) { + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + String refreshToken = jwtProviderService.provideRefreshToken(); + TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null); + + tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest); + response.addCookie( CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); response.addCookie( - CookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(publicClaim), - tokenConfig.getRefreshExpire())); + CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire())); response.addCookie( - CookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(), - tokenConfig.getRefreshExpire())); + CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire())); + } + + public void validTokenPair(Long id, String oldRefreshToken) { + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id); + + if (isNull(tokenSaveValue)) { + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + + if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) { + tokenRepository.delete(id); + + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } } } 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 index f1b3b8a6..50508aa9 100644 --- a/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java +++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java @@ -3,6 +3,7 @@ import com.moabam.api.domain.bug.Bug; 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; @@ -29,4 +30,11 @@ public static LoginResponse toLoginResponse(Member member, boolean isSignUp) { .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/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/infrastructure/redis/HashTemplateRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java new file mode 100644 index 00000000..6a24d889 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java @@ -0,0 +1,43 @@ +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; + +@Repository +public class HashTemplateRepository { + + private final RedisTemplate redisTemplate; + private final HashOperations hashOperations; + private final Jackson2HashMapper hashMapper; + + public HashTemplateRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + hashOperations = redisTemplate.opsForHash(); + hashMapper = new Jackson2HashMapper(false); + } + + 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.size() == 0) { + return null; + } + + return hashMapper.fromHash(memberToken); + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java new file mode 100644 index 00000000..f714d269 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java @@ -0,0 +1,41 @@ +package com.moabam.api.infrastructure.redis; + +import java.time.Duration; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Repository; + +import com.moabam.api.dto.auth.TokenSaveValue; + +@Repository +public class TokenRepository { + + private static final int EXPIRE_DAYS = 14; + + private final HashTemplateRepository hashTemplateRepository; + + @Autowired + public TokenRepository(HashTemplateRepository hashTemplateRepository) { + this.hashTemplateRepository = hashTemplateRepository; + } + + public void saveToken(Long memberId, TokenSaveValue tokenSaveRequest) { + String tokenKey = parseTokenKey(memberId); + + hashTemplateRepository.save(tokenKey, tokenSaveRequest, Duration.ofDays(EXPIRE_DAYS)); + } + + public TokenSaveValue getTokenSaveValue(Long memberId) { + String tokenKey = parseTokenKey(memberId); + return (TokenSaveValue)hashTemplateRepository.get(tokenKey); + } + + public void delete(Long memberId) { + String tokenKey = parseTokenKey(memberId); + hashTemplateRepository.delete(tokenKey); + } + + private String parseTokenKey(Long memberId) { + return "auth_" + memberId; + } +} diff --git a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java index 27368bd4..8e47b231 100644 --- a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java @@ -15,6 +15,7 @@ import com.moabam.api.application.auth.mapper.AuthorizationMapper; import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.common.util.CookieUtils; import com.moabam.global.error.exception.UnauthorizedException; import com.moabam.global.error.model.ErrorMessage; @@ -51,6 +52,7 @@ protected void doFilterInternal(@NotNull HttpServletRequest httpServletRequest, invoke(httpServletRequest, httpServletResponse); } catch (UnauthorizedException unauthorizedException) { log.error("Login Failed"); + expireToken(httpServletRequest, httpServletResponse); handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, unauthorizedException); @@ -92,6 +94,7 @@ private void handleTokenAuthenticate(Cookie[] cookies, throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); } + authorizationService.validTokenPair(publicClaim.id(), refreshToken); authorizationService.issueServiceToken(httpServletResponse, publicClaim); } @@ -110,4 +113,17 @@ private String extractTokenFromCookie(Cookie[] cookies, String tokenName) { .findFirst() .orElseThrow(() -> new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL)); } + + private void expireToken(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)); + } + }); + } } diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index c1220718..7cf36469 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -15,6 +15,12 @@ public static Cookie typeCookie(String value, long expireTime) { return basic("token_type", value, expireTime); } + public static Cookie deleteCookie(Cookie cookie) { + cookie.setMaxAge(0); + cookie.setPath("/"); + return cookie; + } + private static Cookie basic(String name, String value, long expireTime) { Cookie cookie = new Cookie(name, value); cookie.setSecure(true); diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 77a7fcbd..44f50cf2 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -12,7 +12,9 @@ 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.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.util.StringUtils; @@ -58,6 +60,15 @@ public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConne return stringRedisTemplate; } + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + return redisTemplate; + } + public void startRedis() { Os os = Os.createOs(); availablePort = findPort(os); diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index 79c0e85b..3c154692 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -6,7 +6,9 @@ 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.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @Configuration @@ -33,4 +35,13 @@ public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConne return stringRedisTemplate; } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory); + + return redisTemplate; + } } diff --git a/src/main/resources/config b/src/main/resources/config index 90cfb53f..5d59255d 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 90cfb53fec7a953ca50b8727cc1c6770034b85f3 +Subproject commit 5d59255dfe93e90ce0632ddb9a94ba606b45b564 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 47b45ffe..1e51ee34 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 192
+Content-Length: 200
 Host: localhost:8080
 
 {
@@ -483,7 +483,7 @@ 

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 62 +Content-Length: 64 { "message" : "쿠폰의 이름이 중복되었습니다." @@ -514,7 +514,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 56 +Content-Length: 58 { "message" : "존재하지 않는 쿠폰입니다." @@ -546,7 +546,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 56 +Content-Length: 58 { "message" : "존재하지 않는 쿠폰입니다." @@ -569,7 +569,7 @@

요청

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 206bb94a..5823305d 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -473,7 +473,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 64 +Content-Length: 66 { "message" : "이미 콕 알림을 보낸 대상입니다." @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index 53a2ed0a..97c091b7 100644 --- a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -33,12 +33,15 @@ 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.redis.TokenRepository; import com.moabam.global.auth.model.PublicClaim; 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 com.moabam.support.fixture.AuthorizationResponseFixture; +import com.moabam.support.fixture.TokenSaveValueFixture; import jakarta.servlet.http.Cookie; @@ -57,6 +60,9 @@ class AuthorizationServiceTest { @Mock JwtProviderService jwtProviderService; + @Mock + TokenRepository tokenRepository; + OAuthConfig oauthConfig; TokenConfig tokenConfig; AuthorizationService noPropertyService; @@ -82,7 +88,7 @@ public void initParams() { ); noPropertyService = new AuthorizationService(noOAuthConfig, tokenConfig, oAuth2AuthorizationServerRequestService, - memberService, jwtProviderService); + memberService, jwtProviderService, tokenRepository); } @DisplayName("인가코드 URI 생성 매퍼 실패") @@ -247,4 +253,43 @@ void signUp_success(boolean isSignUp) { () -> assertThat(refreshCookie.getPath()).isEqualTo("/") ); } + + @DisplayName("토큰 redis 검증") + @Test + void valid_token_in_redis() { + // Given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L); + + // When + Then + assertThatNoException().isThrownBy(() -> + authorizationService.validTokenPair(1L, "token")); + } + + @DisplayName("토큰이 null 이어서 예외 발생") + @Test + void valid_token_failby_token_is_null() { + // Given + willReturn(null) + .given(tokenRepository).getTokenSaveValue(1L); + + // When + Then + assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "token")) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + } + + @DisplayName("이전 토큰과 동일한지 검증") + @Test + void valid_token_failby_notEquals_token() { + // Given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L); + + // When + Then + assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "oldToken")) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + verify(tokenRepository).delete(1L); + } } diff --git a/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java new file mode 100644 index 00000000..7e929051 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java @@ -0,0 +1,86 @@ +package com.moabam.api.domain.repository; + +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.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.dto.auth.TokenSaveValue; +import com.moabam.api.infrastructure.redis.HashTemplateRepository; +import com.moabam.api.infrastructure.redis.TokenRepository; +import com.moabam.support.fixture.TokenSaveValueFixture; + +@ExtendWith(MockitoExtension.class) +class TokenRepostiroyTest { + + @InjectMocks + TokenRepository tokenRepository; + + @Mock + HashTemplateRepository hashTemplateRepository; + + @DisplayName("토큰 저장 성공") + @Test + void save_token_suceess() { + // Given + willDoNothing().given(hashTemplateRepository).save(any(), any(TokenSaveValue.class), any(Duration.class)); + + // When + Then + Assertions.assertThatNoException() + .isThrownBy(() -> tokenRepository.saveToken(1L, TokenSaveValueFixture.tokenSaveValue())); + } + + @Nested + @DisplayName("토큰 조회") + class Search { + + @DisplayName("성공") + @Test + void token_get_success() { + // given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(hashTemplateRepository).get(anyString()); + + // when + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); + + // then + assertAll( + () -> assertThat(tokenSaveValue).isNotNull(), + () -> assertThat(tokenSaveValue.refreshToken()).isEqualTo("token") + ); + } + + @DisplayName("실패") + @Test + void token_get_fail() { + // given + willReturn(null) + .given(hashTemplateRepository).get(anyString()); + + // when + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); + + // then + assertThat(tokenSaveValue).isNull(); + } + } + + @DisplayName("토큰 저장 삭제") + @Test + void delete_token_suceess() { + // When + Then + Assertions.assertThatNoException() + .isThrownBy(() -> tokenRepository.delete(1L)); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java new file mode 100644 index 00000000..bf138a7f --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java @@ -0,0 +1,64 @@ +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.support.fixture.TokenSaveValueFixture; + +@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, HashTemplateRepository.class}) +class HashTemplateRepositoryTest { + + @Autowired + private HashTemplateRepository hashTemplateRepository; + + String key = "auth_123"; + String token = "token"; + String ip = "ip"; + TokenSaveValue tokenSaveValue = TokenSaveValueFixture.tokenSaveValue(token, ip); + Duration duration = Duration.ofMillis(5000); + + @BeforeEach + void setUp() { + hashTemplateRepository.save(key, (Object)tokenSaveValue, duration); + } + + @AfterEach + void delete() { + hashTemplateRepository.delete(key); + } + + @DisplayName("레디스에 hash 저장 성공") + @Test + void hashTemplate_repository_save_success() { + // Given + When + TokenSaveValue object = (TokenSaveValue)hashTemplateRepository.get(key); + + // Then + assertAll( + () -> assertThat(object.refreshToken()).isEqualTo(token), + () -> assertThat(object.loginIp()).isEqualTo(ip) + ); + } + + @DisplayName("삭제 성공 테스트") + @Test + void delete_and_get_null() { + // Given + hashTemplateRepository.delete(key); + + // When + Then + assertThat(hashTemplateRepository.get(key)).isNull(); + } +} diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java index f4a029ed..50765982 100644 --- a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -1,6 +1,7 @@ package com.moabam.global.filter; import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.io.IOException; @@ -139,6 +140,44 @@ void filter_have_any_refresh_token_error() throws ServletException, IOException eq(null), any(UnauthorizedException.class)); } + @DisplayName("에러 발생 시 모든 토큰 만료") + @Test + void error_with_expire_token() 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)).thenReturn(true); + + // when + authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); + Cookie cookie = httpServletResponse.getCookie("access_token"); + + // then + assertAll( + () -> assertThat(cookie).isNotNull(), + () -> assertThat(cookie.getMaxAge()).isZero(), + () -> assertThat(cookie.getValue()).isEqualTo(token) + ); + + verify(handlerExceptionResolver, times(1)) + .resolveException( + eq(httpServletRequest), eq(httpServletResponse), + eq(null), any(UnauthorizedException.class)); + + } + @DisplayName("새로운 도큰 발급 성공") @Test void issue_new_token_success() throws ServletException, IOException { 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"); + } +} From b06566b29cd0e2b9dedb0cac9407a5be210f2baa Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 16 Nov 2023 14:21:38 +0900 Subject: [PATCH 052/185] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20(#94)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 토큰 redis 저장을 위한 dto 및 config 추가 * feat: webConfig 파일 추가 * feat: redis 토큰 저장 서비스 및 테스트 코드 추가 * feat: 에러시 모든 토큰 제거 추가 * refactor: config update * feat: config 추가 * refactor: code smell 제거 * feat: logout 기능 추가 * refactor: null 예외 처리 변경 * refactor: config 수정 * refactor: merge confilt 수정 * refacotr: code smell 로직 변경 --- .../auth/AuthorizationService.java | 77 +++++++++++-------- .../redis/HashTemplateRepository.java | 5 +- .../api/presentation/MemberController.java | 10 +++ .../auth/filter/AuthorizationFilter.java | 16 +--- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../application/AuthorizationServiceTest.java | 62 +++++++++++---- .../repository/TokenRepostiroyTest.java | 50 ++++-------- .../redis/HashTemplateRepositoryTest.java | 15 +++- .../filter/AuthorizationFilterTest.java | 40 ---------- 12 files changed, 143 insertions(+), 140 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 9ab86b2e..2bdfd695 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -1,6 +1,6 @@ package com.moabam.api.application.auth; -import static java.util.Objects.*; +import java.util.Arrays; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; @@ -20,6 +20,7 @@ import com.moabam.api.dto.auth.LoginResponse; import com.moabam.api.dto.auth.TokenSaveValue; import com.moabam.api.infrastructure.redis.TokenRepository; +import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.common.util.CookieUtils; import com.moabam.global.common.util.GlobalConstant; @@ -29,6 +30,7 @@ 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; @@ -71,6 +73,50 @@ public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, return loginResponse; } + public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) { + String accessToken = jwtProviderService.provideAccessToken(publicClaim); + String refreshToken = jwtProviderService.provideRefreshToken(); + TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null); + + tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest); + + response.addCookie( + CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); + response.addCookie( + CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire())); + response.addCookie( + CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire())); + } + + public void validTokenPair(Long id, String oldRefreshToken) { + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id); + + if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) { + tokenRepository.delete(id); + + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + } + } + + public void logout(AuthorizationMember authorizationMember, HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse) { + removeToken(httpServletRequest, httpServletResponse); + tokenRepository.delete(authorizationMember.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)); + } + }); + } + private String getAuthorizationCodeUri() { AuthorizationCodeRequest authorizationCodeRequest = AuthorizationMapper.toAuthorizationCodeRequest(oAuthConfig); return generateQueryParamsWith(authorizationCodeRequest); @@ -126,33 +172,4 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq return contents; } - - public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) { - String accessToken = jwtProviderService.provideAccessToken(publicClaim); - String refreshToken = jwtProviderService.provideRefreshToken(); - TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null); - - tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest); - - response.addCookie( - CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); - response.addCookie( - CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire())); - response.addCookie( - CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire())); - } - - public void validTokenPair(Long id, String oldRefreshToken) { - TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id); - - if (isNull(tokenSaveValue)) { - throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); - } - - if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) { - tokenRepository.delete(id); - - throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); - } - } } diff --git a/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java index 6a24d889..12b1d291 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java @@ -9,6 +9,9 @@ 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 HashTemplateRepository { @@ -35,7 +38,7 @@ public Object get(String key) { Map memberToken = hashOperations.entries(key); if (memberToken.size() == 0) { - return null; + throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); } return hashMapper.fromHash(memberToken); diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index e820777f..87e6e0f4 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -12,7 +12,10 @@ import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; import com.moabam.api.dto.auth.AuthorizationTokenResponse; import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -38,4 +41,11 @@ public LoginResponse authorizationTokenIssue(@ModelAttribute AuthorizationCodeRe return authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); } + + @GetMapping("/logout") + @ResponseStatus(HttpStatus.OK) + public void logout(@CurrentMember AuthorizationMember authorizationMember, + HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + authorizationService.logout(authorizationMember, httpServletRequest, httpServletResponse); + } } diff --git a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java index 8e47b231..c850fd1d 100644 --- a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java @@ -15,7 +15,6 @@ import com.moabam.api.application.auth.mapper.AuthorizationMapper; import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.auth.model.PublicClaim; -import com.moabam.global.common.util.CookieUtils; import com.moabam.global.error.exception.UnauthorizedException; import com.moabam.global.error.model.ErrorMessage; @@ -52,7 +51,7 @@ protected void doFilterInternal(@NotNull HttpServletRequest httpServletRequest, invoke(httpServletRequest, httpServletResponse); } catch (UnauthorizedException unauthorizedException) { log.error("Login Failed"); - expireToken(httpServletRequest, httpServletResponse); + authorizationService.removeToken(httpServletRequest, httpServletResponse); handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, unauthorizedException); @@ -113,17 +112,4 @@ private String extractTokenFromCookie(Cookie[] cookies, String tokenName) { .findFirst() .orElseThrow(() -> new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL)); } - - private void expireToken(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)); - } - }); - } } diff --git a/src/main/resources/config b/src/main/resources/config index 5d59255d..60fc7d54 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 5d59255dfe93e90ce0632ddb9a94ba606b45b564 +Subproject commit 60fc7d547a4973660212bd8c139e778faa9c3de7 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 1e51ee34..be894213 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 65d67188..5801bc5c 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 5823305d..e637c515 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index 97c091b7..5d9837d7 100644 --- a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -19,6 +19,7 @@ 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; @@ -34,18 +35,22 @@ import com.moabam.api.dto.auth.AuthorizationTokenResponse; import com.moabam.api.dto.auth.LoginResponse; import com.moabam.api.infrastructure.redis.TokenRepository; +import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.PublicClaim; +import com.moabam.global.common.util.CookieUtils; 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 com.moabam.support.annotation.WithMember; +import com.moabam.support.common.FilterProcessExtension; import com.moabam.support.fixture.AuthorizationResponseFixture; import com.moabam.support.fixture.TokenSaveValueFixture; import jakarta.servlet.http.Cookie; -@ExtendWith(MockitoExtension.class) +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class AuthorizationServiceTest { @InjectMocks @@ -266,19 +271,6 @@ void valid_token_in_redis() { authorizationService.validTokenPair(1L, "token")); } - @DisplayName("토큰이 null 이어서 예외 발생") - @Test - void valid_token_failby_token_is_null() { - // Given - willReturn(null) - .given(tokenRepository).getTokenSaveValue(1L); - - // When + Then - assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "token")) - .isInstanceOf(UnauthorizedException.class) - .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); - } - @DisplayName("이전 토큰과 동일한지 검증") @Test void valid_token_failby_notEquals_token() { @@ -292,4 +284,46 @@ void valid_token_failby_notEquals_token() { .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); verify(tokenRepository).delete(1L); } + + @DisplayName("토큰 삭제 성공") + @Test + void error_with_expire_token(@WithMember AuthorizationMember authorizationMember) { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + httpServletRequest.setCookies( + CookieUtils.tokenCookie("access_token", "value", 100000), + CookieUtils.tokenCookie("refresh_token", "value", 100000), + CookieUtils.typeCookie("Bearer", 100000) + ); + + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + // When + authorizationService.logout(authorizationMember, httpServletRequest, httpServletResponse); + Cookie cookie = httpServletResponse.getCookie("access_token"); + + // Then + assertAll( + () -> assertThat(cookie).isNotNull(), + () -> assertThat(cookie.getMaxAge()).isZero(), + () -> assertThat(cookie.getValue()).isEqualTo("value") + ); + + verify(tokenRepository).delete(authorizationMember.id()); + } + + @DisplayName("토큰 없어서 삭제 실패") + @Test + void token_null_delete_fail(@WithMember AuthorizationMember authorizationMember) { + // given + MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); + MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); + + // When + authorizationService.logout(authorizationMember, httpServletRequest, httpServletResponse); + Cookie cookie = httpServletResponse.getCookie("access_token"); + + // Then + assertThat(httpServletResponse.getCookies()).isEmpty(); + } } diff --git a/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java index 7e929051..5f8a473f 100644 --- a/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java +++ b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java @@ -8,7 +8,6 @@ import 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.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -40,40 +39,21 @@ void save_token_suceess() { .isThrownBy(() -> tokenRepository.saveToken(1L, TokenSaveValueFixture.tokenSaveValue())); } - @Nested - @DisplayName("토큰 조회") - class Search { - - @DisplayName("성공") - @Test - void token_get_success() { - // given - willReturn(TokenSaveValueFixture.tokenSaveValue("token")) - .given(hashTemplateRepository).get(anyString()); - - // when - TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); - - // then - assertAll( - () -> assertThat(tokenSaveValue).isNotNull(), - () -> assertThat(tokenSaveValue.refreshToken()).isEqualTo("token") - ); - } - - @DisplayName("실패") - @Test - void token_get_fail() { - // given - willReturn(null) - .given(hashTemplateRepository).get(anyString()); - - // when - TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); - - // then - assertThat(tokenSaveValue).isNull(); - } + @DisplayName("토큰 조회 성공") + @Test + void token_get_success() { + // given + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(hashTemplateRepository).get(anyString()); + + // when + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); + + // then + assertAll( + () -> assertThat(tokenSaveValue).isNotNull(), + () -> assertThat(tokenSaveValue.refreshToken()).isEqualTo("token") + ); } @DisplayName("토큰 저장 삭제") diff --git a/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java index bf138a7f..9998b667 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java @@ -15,6 +15,8 @@ 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, HashTemplateRepository.class}) @@ -59,6 +61,17 @@ void delete_and_get_null() { hashTemplateRepository.delete(key); // When + Then - assertThat(hashTemplateRepository.get(key)).isNull(); + assertThatThrownBy(() -> hashTemplateRepository.get(key)) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); + } + + @DisplayName("토큰이 null 이어서 예외 발생") + @Test + void valid_token_failby_token_is_null() { + // Given + When + Then + assertThatThrownBy(() -> hashTemplateRepository.get("0")) + .isInstanceOf(UnauthorizedException.class) + .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); } } diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java index 50765982..ffe04344 100644 --- a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -1,7 +1,6 @@ package com.moabam.global.filter; import static org.assertj.core.api.Assertions.*; -import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; import java.io.IOException; @@ -140,44 +139,6 @@ void filter_have_any_refresh_token_error() throws ServletException, IOException eq(null), any(UnauthorizedException.class)); } - @DisplayName("에러 발생 시 모든 토큰 만료") - @Test - void error_with_expire_token() 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)).thenReturn(true); - - // when - authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); - Cookie cookie = httpServletResponse.getCookie("access_token"); - - // then - assertAll( - () -> assertThat(cookie).isNotNull(), - () -> assertThat(cookie.getMaxAge()).isZero(), - () -> assertThat(cookie.getValue()).isEqualTo(token) - ); - - verify(handlerExceptionResolver, times(1)) - .resolveException( - eq(httpServletRequest), eq(httpServletResponse), - eq(null), any(UnauthorizedException.class)); - - } - @DisplayName("새로운 도큰 발급 성공") @Test void issue_new_token_success() throws ServletException, IOException { @@ -210,5 +171,4 @@ void issue_new_token_success() throws ServletException, IOException { AuthorizationMember authorizationMember = AuthorizationThreadLocal.getAuthorizationMember(); assertThat(authorizationMember.id()).isEqualTo(1L); } - } From 88ba4fc405cd70eeaa00f61205346b73dad48115 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 16 Nov 2023 15:54:37 +0900 Subject: [PATCH 053/185] =?UTF-8?q?fix:=20config=20=EC=88=98=EC=A0=95=20(#?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 60fc7d54..40c3582c 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 60fc7d547a4973660212bd8c139e778faa9c3de7 +Subproject commit 40c3582cf6f558a08451e678fb8cb451b2869107 From a7dedc4b87bb2509449847b0630b0382ea5ecb6c Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Thu, 16 Nov 2023 17:32:26 +0900 Subject: [PATCH 054/185] =?UTF-8?q?feat:=20=EC=B0=B8=EC=97=AC=EC=A4=91?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=A9=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20(#95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 참여중인 방 목록 조회 기능 구현 * feat: 관련 Repository 구현 * test: 참여중인 방 목록 조회 테스트 작성 --- .../room/RoomCertificationService.java | 9 ++ .../application/room/RoomSearchService.java | 22 ++++- .../application/room/mapper/RoomMapper.java | 23 +++++ .../DailyMemberCertificationRepository.java | 4 + .../DailyRoomCertificationRepository.java | 3 + .../ParticipantSearchRepository.java | 13 ++- .../moabam/api/dto/room/MyRoomResponse.java | 20 +++++ .../moabam/api/dto/room/MyRoomsResponse.java | 12 +++ .../api/presentation/RoomController.java | 7 ++ .../room/RoomSearchServiceTest.java | 90 +++++++++++++++++++ .../api/presentation/RoomControllerTest.java | 27 ++++++ .../moabam/support/fixture/RoomFixture.java | 17 ++++ 12 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/room/MyRoomResponse.java create mode 100644 src/main/java/com/moabam/api/dto/room/MyRoomsResponse.java create mode 100644 src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java diff --git a/src/main/java/com/moabam/api/application/room/RoomCertificationService.java b/src/main/java/com/moabam/api/application/room/RoomCertificationService.java index c85cc1bd..13998473 100644 --- a/src/main/java/com/moabam/api/application/room/RoomCertificationService.java +++ b/src/main/java/com/moabam/api/application/room/RoomCertificationService.java @@ -112,6 +112,15 @@ public void certifyRoom(Long memberId, Long roomId, List multipar } } + 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); + } + private void validateCertifyTime(LocalDateTime now, int certifyTime) { LocalTime targetTime = LocalTime.of(certifyTime, 0); LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index 7b063005..fd25b035 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -26,6 +26,8 @@ import com.moabam.api.domain.room.repository.ParticipantSearchRepository; import com.moabam.api.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.CertificationImageResponse; +import com.moabam.api.dto.room.MyRoomResponse; +import com.moabam.api.dto.room.MyRoomsResponse; import com.moabam.api.dto.room.RoomDetailsResponse; import com.moabam.api.dto.room.RoutineResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; @@ -42,6 +44,7 @@ public class RoomSearchService { private final ParticipantSearchRepository participantSearchRepository; private final RoutineSearchRepository routineSearchRepository; private final MemberService memberService; + private final RoomCertificationService roomCertificationService; public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { LocalDate today = LocalDate.now(); @@ -63,6 +66,23 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { todayCertificateRankResponses, completePercentage); } + public MyRoomsResponse getMyRooms(Long memberId) { + LocalDate today = LocalDate.now(); + List myRoomResponses = new ArrayList<>(); + List participants = participantSearchRepository.findParticipantsByMemberId(memberId); + + for (Participant participant : participants) { + Room room = participant.getRoom(); + boolean isMemberCertified = roomCertificationService.existsMemberCertification(memberId, room.getId(), + today); + boolean isRoomCertified = roomCertificationService.existsRoomCertification(room.getId(), today); + + myRoomResponses.add(RoomMapper.toMyRoomResponse(room, isMemberCertified, isRoomCertified)); + } + + return RoomMapper.toMyRoomsResponse(myRoomResponses); + } + private List getRoutineResponses(Long roomId) { List roomRoutines = routineSearchRepository.findAllByRoomId(roomId); @@ -74,7 +94,7 @@ private List getTodayCertificateRankResponses(Long List responses = new ArrayList<>(); List certifications = certificationsSearchRepository.findCertifications(roomId, today); - List participants = participantSearchRepository.findParticipants(roomId); + List participants = participantSearchRepository.findParticipantsByRoomId(roomId); List members = memberService.getRoomMembers(participants.stream() .map(Participant::getMemberId) .toList()); 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 index 5b005f0e..d304bbc1 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -5,6 +5,8 @@ import com.moabam.api.domain.room.Room; import com.moabam.api.dto.room.CreateRoomRequest; +import com.moabam.api.dto.room.MyRoomResponse; +import com.moabam.api.dto.room.MyRoomsResponse; import com.moabam.api.dto.room.RoomDetailsResponse; import com.moabam.api.dto.room.RoutineResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; @@ -45,4 +47,25 @@ public static RoomDetailsResponse toRoomDetailsResponse(Room room, String manage .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(); + } } 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 index 2d3f7fa2..19f61f00 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java @@ -1,9 +1,13 @@ 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); } 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 index 16097e8d..47194085 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/DailyRoomCertificationRepository.java @@ -1,9 +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/ParticipantSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java index a00f7ea4..9febf65b 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -34,7 +34,7 @@ public Optional findOne(Long memberId, Long roomId) { ); } - public List findParticipants(Long roomId) { + public List findParticipantsByRoomId(Long roomId) { return jpaQueryFactory .selectFrom(participant) .where( @@ -44,6 +44,17 @@ public List findParticipants(Long roomId) { .fetch(); } + public List findParticipantsByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .join(participant.room, room).fetchJoin() + .where( + participant.memberId.eq(memberId), + participant.deletedAt.isNull() + ) + .fetch(); + } + public List findOtherParticipantsInRoom(Long memberId, Long roomId) { return jpaQueryFactory .selectFrom(participant) 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/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 314ac697..35db06c7 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -21,6 +21,7 @@ import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; +import com.moabam.api.dto.room.MyRoomsResponse; import com.moabam.api.dto.room.RoomDetailsResponse; import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -98,4 +99,10 @@ public void deportParticipant(@CurrentMember AuthorizationMember authorizationMe roomService.deportParticipant(authorizationMember.id(), roomId, memberId); } + + @GetMapping("/my-join") + @ResponseStatus(HttpStatus.OK) + public MyRoomsResponse getMyRooms(@CurrentMember AuthorizationMember authorizationMember) { + return roomSearchService.getMyRooms(authorizationMember.id()); + } } diff --git a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java new file mode 100644 index 00000000..de0bfffe --- /dev/null +++ b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java @@ -0,0 +1,90 @@ +package com.moabam.api.application.room; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDate; +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.repository.CertificationsSearchRepository; +import com.moabam.api.domain.room.repository.ParticipantSearchRepository; +import com.moabam.api.domain.room.repository.RoutineSearchRepository; +import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.support.fixture.RoomFixture; + +@ExtendWith(MockitoExtension.class) +class RoomSearchServiceTest { + + @InjectMocks + private RoomSearchService roomSearchService; + + @Mock + private CertificationsSearchRepository certificationsSearchRepository; + + @Mock + private ParticipantSearchRepository participantSearchRepository; + + @Mock + private RoutineSearchRepository routineSearchRepository; + + @Mock + private MemberService memberService; + + @Mock + private RoomCertificationService certificationService; + + @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)); + + lenient().when(room1.getId()).thenReturn(1L); + lenient().when(room2.getId()).thenReturn(2L); + lenient().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.findParticipantsByMemberId(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); + + // when + MyRoomsResponse myRooms = roomSearchService.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(); + } +} diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 7ba6206a..c2f66f13 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -858,4 +858,31 @@ void deport_member_success() throws Exception { assertThat(getMemberParticipant.getDeletedAt()).isNotNull(); assertThat(participantSearchRepository.findOne(member.getId(), room.getId())).isEmpty(); } + + @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()); + } } diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java index 088429e2..ccdce73f 100644 --- a/src/test/java/com/moabam/support/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -37,6 +37,23 @@ public static Room room(int certifyTime) { .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 List rooms() { + return List.of( + room("아침 - 첫 번째 방", RoomType.MORNING, 10), + room("아침 - 두 번째 방", RoomType.MORNING, 9), + room("밤 - 첫 번째 방", RoomType.NIGHT, 22) + ); + } + public static Participant participant(Room room, Long memberId) { return Participant.builder() .room(room) From 17247e13879fb29944b6c719812bb020ed6b38e5 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 16 Nov 2023 17:55:10 +0900 Subject: [PATCH 055/185] =?UTF-8?q?hotfix:=20redis=20config=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 40c3582c..4c6ff3e3 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 40c3582cf6f558a08451e678fb8cb451b2869107 +Subproject commit 4c6ff3e36168671367e3f3ff72c659650b79e68f From 58d528084fe0877e00081ee3ed2f639745c55ce2 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 16 Nov 2023 18:11:00 +0900 Subject: [PATCH 056/185] =?UTF-8?q?refactor:=20=EB=B2=8C=EB=A0=88=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20(#97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 벌레 상품 조회 API URL 변경 * style: 테스트 패키지 구조 변경 --- .../api/application/bug/BugService.java | 12 ++++ .../application/product/ProductService.java | 26 -------- .../product/repository/ProductRepository.java | 4 ++ .../api/presentation/BugController.java | 7 +++ .../api/presentation/ProductController.java | 26 -------- .../api/application/ProductServiceTest.java | 48 --------------- .../application/{ => bug}/BugServiceTest.java | 32 +++++++++- .../{ => item}/ItemServiceTest.java | 3 +- .../api/domain/{entity => bug}/BugTest.java | 4 +- .../api/domain/{entity => item}/ItemTest.java | 3 +- .../InventorySearchRepositoryTest.java | 5 +- .../repository/ItemSearchRepositoryTest.java | 5 +- .../{entity => product}/ProductTest.java | 3 +- .../api/presentation/BugControllerTest.java | 27 +++++++++ .../presentation/ProductControllerTest.java | 60 ------------------- 15 files changed, 86 insertions(+), 179 deletions(-) delete mode 100644 src/main/java/com/moabam/api/application/product/ProductService.java delete mode 100644 src/main/java/com/moabam/api/presentation/ProductController.java delete mode 100644 src/test/java/com/moabam/api/application/ProductServiceTest.java rename src/test/java/com/moabam/api/application/{ => bug}/BugServiceTest.java (53%) rename src/test/java/com/moabam/api/application/{ => item}/ItemServiceTest.java (98%) rename src/test/java/com/moabam/api/domain/{entity => bug}/BugTest.java (94%) rename src/test/java/com/moabam/api/domain/{entity => item}/ItemTest.java (97%) rename src/test/java/com/moabam/api/domain/{ => item}/repository/InventorySearchRepositoryTest.java (93%) rename src/test/java/com/moabam/api/domain/{ => item}/repository/ItemSearchRepositoryTest.java (92%) rename src/test/java/com/moabam/api/domain/{entity => product}/ProductTest.java (92%) delete mode 100644 src/test/java/com/moabam/api/presentation/ProductControllerTest.java diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 76b10a9f..60222eb5 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -2,6 +2,7 @@ import static com.moabam.api.domain.bug.BugActionType.*; import static com.moabam.api.domain.bug.BugType.*; +import static com.moabam.api.domain.product.ProductType.*; import java.util.List; @@ -9,12 +10,16 @@ import org.springframework.transaction.annotation.Transactional; import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.product.ProductMapper; import com.moabam.api.domain.bug.BugHistory; import com.moabam.api.domain.bug.BugType; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; import com.moabam.api.domain.member.Member; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; import com.moabam.global.common.util.ClockHolder; import lombok.RequiredArgsConstructor; @@ -26,6 +31,7 @@ public class BugService { private final MemberService memberService; private final BugHistorySearchRepository bugHistorySearchRepository; + private final ProductRepository productRepository; private final ClockHolder clockHolder; public BugResponse getBug(Long memberId) { @@ -42,6 +48,12 @@ public TodayBugResponse getTodayBug(Long memberId) { return BugMapper.toTodayBugResponse(morningBug, nightBug); } + public ProductsResponse getBugProducts() { + List products = productRepository.findAllByType(BUG); + + return ProductMapper.toProductsResponse(products); + } + private int calculateBugQuantity(List bugHistory, BugType bugType) { return bugHistory.stream() .filter(history -> bugType.equals(history.getBugType())) diff --git a/src/main/java/com/moabam/api/application/product/ProductService.java b/src/main/java/com/moabam/api/application/product/ProductService.java deleted file mode 100644 index d5d389f3..00000000 --- a/src/main/java/com/moabam/api/application/product/ProductService.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.moabam.api.application.product; - -import java.util.List; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.moabam.api.domain.product.Product; -import com.moabam.api.domain.product.repository.ProductRepository; -import com.moabam.api.dto.product.ProductsResponse; - -import lombok.RequiredArgsConstructor; - -@Service -@Transactional(readOnly = true) -@RequiredArgsConstructor -public class ProductService { - - private final ProductRepository productRepository; - - public ProductsResponse getProducts() { - List products = productRepository.findAll(); - - return ProductMapper.toProductsResponse(products); - } -} 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 index 4156d9a9..358aa0c0 100644 --- a/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java +++ b/src/main/java/com/moabam/api/domain/product/repository/ProductRepository.java @@ -1,9 +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/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index edb99b76..41dfa8d1 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -9,6 +9,7 @@ import com.moabam.api.application.bug.BugService; import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -32,4 +33,10 @@ public BugResponse getBug(@CurrentMember AuthorizationMember member) { public TodayBugResponse getTodayBug(@CurrentMember AuthorizationMember member) { return bugService.getTodayBug(member.id()); } + + @GetMapping("/products") + @ResponseStatus(HttpStatus.OK) + public ProductsResponse getBugProducts() { + return bugService.getBugProducts(); + } } diff --git a/src/main/java/com/moabam/api/presentation/ProductController.java b/src/main/java/com/moabam/api/presentation/ProductController.java deleted file mode 100644 index cad9cd79..00000000 --- a/src/main/java/com/moabam/api/presentation/ProductController.java +++ /dev/null @@ -1,26 +0,0 @@ -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.product.ProductService; -import com.moabam.api.dto.product.ProductsResponse; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/products") -@RequiredArgsConstructor -public class ProductController { - - private final ProductService productService; - - @GetMapping - @ResponseStatus(HttpStatus.OK) - public ProductsResponse getProducts() { - return productService.getProducts(); - } -} diff --git a/src/test/java/com/moabam/api/application/ProductServiceTest.java b/src/test/java/com/moabam/api/application/ProductServiceTest.java deleted file mode 100644 index 24b331a6..00000000 --- a/src/test/java/com/moabam/api/application/ProductServiceTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.moabam.api.application; - -import static com.moabam.support.fixture.ProductFixture.*; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -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.product.ProductService; -import com.moabam.api.domain.product.Product; -import com.moabam.api.domain.product.repository.ProductRepository; -import com.moabam.api.dto.product.ProductResponse; -import com.moabam.api.dto.product.ProductsResponse; -import com.moabam.global.common.util.StreamUtils; - -@ExtendWith(MockitoExtension.class) -class ProductServiceTest { - - @InjectMocks - ProductService productService; - - @Mock - ProductRepository productRepository; - - @DisplayName("상품 목록을 조회한다.") - @Test - void get_products_success() { - // given - Product product1 = bugProduct(); - Product product2 = bugProduct(); - given(productRepository.findAll()).willReturn(List.of(product1, product2)); - - // when - ProductsResponse response = productService.getProducts(); - - // then - List productNames = StreamUtils.map(response.products(), ProductResponse::name); - assertThat(response.products()).hasSize(2); - assertThat(productNames).containsExactly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); - } -} diff --git a/src/test/java/com/moabam/api/application/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java similarity index 53% rename from src/test/java/com/moabam/api/application/BugServiceTest.java rename to src/test/java/com/moabam/api/application/bug/BugServiceTest.java index 27b97d1a..6d65a2e3 100644 --- a/src/test/java/com/moabam/api/application/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -1,9 +1,13 @@ -package com.moabam.api.application; +package com.moabam.api.application.bug; +import static com.moabam.api.domain.product.ProductType.*; 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -11,11 +15,15 @@ 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.Bug; import com.moabam.api.domain.member.Member; +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.global.common.util.StreamUtils; @ExtendWith(MockitoExtension.class) class BugServiceTest { @@ -26,6 +34,9 @@ class BugServiceTest { @Mock MemberService memberService; + @Mock + ProductRepository productRepository; + @DisplayName("벌레를 조회한다.") @Test void get_bug_success() { @@ -43,4 +54,21 @@ void get_bug_success() { 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); + } } diff --git a/src/test/java/com/moabam/api/application/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java similarity index 98% rename from src/test/java/com/moabam/api/application/ItemServiceTest.java rename to src/test/java/com/moabam/api/application/item/ItemServiceTest.java index 57f69232..c48fcb12 100644 --- a/src/test/java/com/moabam/api/application/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.application; +package com.moabam.api.application.item; import static com.moabam.support.fixture.InventoryFixture.*; import static com.moabam.support.fixture.ItemFixture.*; @@ -19,7 +19,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.application.item.ItemService; import com.moabam.api.application.member.MemberService; import com.moabam.api.domain.bug.BugHistory; import com.moabam.api.domain.bug.BugType; diff --git a/src/test/java/com/moabam/api/domain/entity/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java similarity index 94% rename from src/test/java/com/moabam/api/domain/entity/BugTest.java rename to src/test/java/com/moabam/api/domain/bug/BugTest.java index 6a00a0f9..f2720165 100644 --- a/src/test/java/com/moabam/api/domain/entity/BugTest.java +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.entity; +package com.moabam.api.domain.bug; import static com.moabam.support.fixture.BugFixture.*; import static org.assertj.core.api.Assertions.*; @@ -10,8 +10,6 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import com.moabam.api.domain.bug.Bug; -import com.moabam.api.domain.bug.BugType; import com.moabam.global.error.exception.BadRequestException; class BugTest { diff --git a/src/test/java/com/moabam/api/domain/entity/ItemTest.java b/src/test/java/com/moabam/api/domain/item/ItemTest.java similarity index 97% rename from src/test/java/com/moabam/api/domain/entity/ItemTest.java rename to src/test/java/com/moabam/api/domain/item/ItemTest.java index be0b518b..326dd553 100644 --- a/src/test/java/com/moabam/api/domain/entity/ItemTest.java +++ b/src/test/java/com/moabam/api/domain/item/ItemTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.entity; +package com.moabam.api.domain.item; import static com.moabam.support.fixture.ItemFixture.*; import static org.assertj.core.api.Assertions.*; @@ -11,7 +11,6 @@ import org.junit.jupiter.params.provider.CsvSource; import com.moabam.api.domain.bug.BugType; -import com.moabam.api.domain.item.Item; import com.moabam.global.error.exception.BadRequestException; class ItemTest { diff --git a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java similarity index 93% rename from src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java rename to src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java index babd5c2e..58f2a8df 100644 --- a/src/test/java/com/moabam/api/domain/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.item.repository; import static com.moabam.support.fixture.InventoryFixture.*; import static com.moabam.support.fixture.ItemFixture.*; @@ -16,9 +16,6 @@ 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.member.Member; import com.moabam.api.domain.member.repository.MemberRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; diff --git a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java similarity index 92% rename from src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java rename to src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java index 6e7e16ff..1d1988a7 100644 --- a/src/test/java/com/moabam/api/domain/repository/ItemSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/ItemSearchRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.domain.repository; +package com.moabam.api.domain.item.repository; import static com.moabam.support.fixture.InventoryFixture.*; import static com.moabam.support.fixture.ItemFixture.*; @@ -13,9 +13,6 @@ 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.domain.item.repository.ItemSearchRepository; import com.moabam.support.annotation.QuerydslRepositoryTest; @QuerydslRepositoryTest diff --git a/src/test/java/com/moabam/api/domain/entity/ProductTest.java b/src/test/java/com/moabam/api/domain/product/ProductTest.java similarity index 92% rename from src/test/java/com/moabam/api/domain/entity/ProductTest.java rename to src/test/java/com/moabam/api/domain/product/ProductTest.java index f960a30a..4e905697 100644 --- a/src/test/java/com/moabam/api/domain/entity/ProductTest.java +++ b/src/test/java/com/moabam/api/domain/product/ProductTest.java @@ -1,11 +1,10 @@ -package com.moabam.api.domain.entity; +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.api.domain.product.Product; import com.moabam.global.error.exception.BadRequestException; class ProductTest { diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 6431727b..c0bf7dbd 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -4,6 +4,7 @@ import static com.moabam.support.fixture.BugFixture.*; import static com.moabam.support.fixture.BugHistoryFixture.*; import static com.moabam.support.fixture.MemberFixture.*; +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.*; @@ -26,10 +27,14 @@ 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.repository.BugHistoryRepository; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; +import com.moabam.api.domain.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.product.ProductsResponse; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; @@ -53,6 +58,9 @@ class BugControllerTest extends WithoutFilterSupporter { @Autowired BugHistorySearchRepository bugHistorySearchRepository; + @Autowired + ProductRepository productRepository; + @DisplayName("벌레를 조회한다.") @WithMember @Test @@ -97,4 +105,23 @@ void get_today_bug_success() throws Exception { TodayBugResponse actual = objectMapper.readValue(content, TodayBugResponse.class); assertThat(actual).isEqualTo(expected); } + + @DisplayName("벌레 상품 목록을 조회한다.") + @Test + void get_products_success() throws Exception { + // given + List products = productRepository.saveAll(List.of(bugProduct(), bugProduct())); + ProductsResponse expected = ProductMapper.toProductsResponse(products); + + // when, then + 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); + } } diff --git a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java b/src/test/java/com/moabam/api/presentation/ProductControllerTest.java deleted file mode 100644 index df4f0c0f..00000000 --- a/src/test/java/com/moabam/api/presentation/ProductControllerTest.java +++ /dev/null @@ -1,60 +0,0 @@ -package com.moabam.api.presentation; - -import static com.moabam.support.fixture.ProductFixture.*; -import static java.nio.charset.StandardCharsets.*; -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.util.List; - -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.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.application.product.ProductMapper; -import com.moabam.api.domain.product.Product; -import com.moabam.api.domain.product.repository.ProductRepository; -import com.moabam.api.dto.product.ProductsResponse; -import com.moabam.support.common.WithoutFilterSupporter; - -@Transactional -@SpringBootTest -@AutoConfigureMockMvc -class ProductControllerTest extends WithoutFilterSupporter { - - @Autowired - MockMvc mockMvc; - - @Autowired - ObjectMapper objectMapper; - - @Autowired - ProductRepository productRepository; - - @DisplayName("상품 목록을 조회한다.") - @Test - void get_products_success() throws Exception { - // given - List products = productRepository.saveAll(List.of(bugProduct(), bugProduct())); - ProductsResponse expected = ProductMapper.toProductsResponse(products); - - // when, then - String content = mockMvc.perform(get("/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); - } -} From 63030bb36f401560d321072cc843398f371838c1 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Fri, 17 Nov 2023 14:48:10 +0900 Subject: [PATCH 057/185] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20Response=EC=97=90=20?= =?UTF-8?q?=ED=98=84=EC=9E=AC=20=EC=A0=81=EC=9A=A9=EB=90=9C=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=ED=85=9C=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 아이템 목록 조회 시 defaultItemId 속성 추가 * test: default 아이템 속성 추가 반영 * style: TodayBugResponse 패키지 위치 변경 --- .../java/com/moabam/api/application/bug/BugMapper.java | 2 +- .../java/com/moabam/api/application/bug/BugService.java | 2 +- .../java/com/moabam/api/application/item/ItemMapper.java | 3 ++- .../java/com/moabam/api/application/item/ItemService.java | 8 +++++++- .../com/moabam/api/dto/{ => bug}/TodayBugResponse.java | 2 +- src/main/java/com/moabam/api/dto/item/ItemsResponse.java | 1 + .../java/com/moabam/api/presentation/BugController.java | 2 +- .../java/com/moabam/global/error/model/ErrorMessage.java | 1 + .../com/moabam/api/application/item/ItemServiceTest.java | 2 ++ .../com/moabam/api/presentation/BugControllerTest.java | 2 +- .../com/moabam/api/presentation/ItemControllerTest.java | 6 ++++-- 11 files changed, 22 insertions(+), 9 deletions(-) rename src/main/java/com/moabam/api/dto/{ => bug}/TodayBugResponse.java (76%) diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index 89a4fe83..0095b05a 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -4,8 +4,8 @@ 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.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.bug.TodayBugResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 60222eb5..6093f51d 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -17,8 +17,8 @@ import com.moabam.api.domain.member.Member; import com.moabam.api.domain.product.Product; import com.moabam.api.domain.product.repository.ProductRepository; -import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.bug.TodayBugResponse; import com.moabam.api.dto.product.ProductsResponse; import com.moabam.global.common.util.ClockHolder; diff --git a/src/main/java/com/moabam/api/application/item/ItemMapper.java b/src/main/java/com/moabam/api/application/item/ItemMapper.java index 7499d561..f91591df 100644 --- a/src/main/java/com/moabam/api/application/item/ItemMapper.java +++ b/src/main/java/com/moabam/api/application/item/ItemMapper.java @@ -27,8 +27,9 @@ public static ItemResponse toItemResponse(Item item) { .build(); } - public static ItemsResponse toItemsResponse(List purchasedItems, List notPurchasedItems) { + 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(); diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java index d1d278aa..3a024c2e 100644 --- a/src/main/java/com/moabam/api/application/item/ItemService.java +++ b/src/main/java/com/moabam/api/application/item/ItemService.java @@ -39,10 +39,11 @@ public class ItemService { private final BugHistoryRepository bugHistoryRepository; 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(purchasedItems, notPurchasedItems); + return ItemMapper.toItemsResponse(defaultItem.getId(), purchasedItems, notPurchasedItems); } @Transactional @@ -80,6 +81,11 @@ private Inventory getInventory(Long memberId, Long 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 -> { diff --git a/src/main/java/com/moabam/api/dto/TodayBugResponse.java b/src/main/java/com/moabam/api/dto/bug/TodayBugResponse.java similarity index 76% rename from src/main/java/com/moabam/api/dto/TodayBugResponse.java rename to src/main/java/com/moabam/api/dto/bug/TodayBugResponse.java index fb5282d5..11ee0dcc 100644 --- a/src/main/java/com/moabam/api/dto/TodayBugResponse.java +++ b/src/main/java/com/moabam/api/dto/bug/TodayBugResponse.java @@ -1,4 +1,4 @@ -package com.moabam.api.dto; +package com.moabam.api.dto.bug; import lombok.Builder; diff --git a/src/main/java/com/moabam/api/dto/item/ItemsResponse.java b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java index 085d339d..70de47ec 100644 --- a/src/main/java/com/moabam/api/dto/item/ItemsResponse.java +++ b/src/main/java/com/moabam/api/dto/item/ItemsResponse.java @@ -6,6 +6,7 @@ @Builder public record ItemsResponse( + Long defaultItemId, List purchasedItems, List notPurchasedItems ) { diff --git a/src/main/java/com/moabam/api/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 41dfa8d1..95de7f5b 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -7,8 +7,8 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.bug.BugService; -import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.bug.TodayBugResponse; import com.moabam.api.dto.product.ProductsResponse; import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index a1bc6d53..90c493c0 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -40,6 +40,7 @@ public enum ErrorMessage { ITEM_UNLOCK_LEVEL_HIGH("아이템 해금 레벨이 높습니다."), ITEM_NOT_PURCHASABLE_BY_BUG_TYPE("해당 벌레 타입으로는 구매할 수 없는 아이템입니다."), INVENTORY_NOT_FOUND("구매하지 않은 아이템은 적용할 수 없습니다."), + DEFAULT_INVENTORY_NOT_FOUND("현재 적용된 아이템이 없습니다."), INVENTORY_CONFLICT("이미 구매한 아이템입니다."), INVALID_BUG_COUNT("벌레 개수는 0 이상이어야 합니다."), diff --git a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java index c48fcb12..c0f06cab 100644 --- a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -70,6 +70,8 @@ void get_products_success() { 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()); diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index c0bf7dbd..a2c66b1e 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -32,8 +32,8 @@ import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; import com.moabam.api.domain.product.Product; import com.moabam.api.domain.product.repository.ProductRepository; -import com.moabam.api.dto.TodayBugResponse; import com.moabam.api.dto.bug.BugResponse; +import com.moabam.api.dto.bug.TodayBugResponse; import com.moabam.api.dto.product.ProductsResponse; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 78e367c4..120f210a 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -30,6 +30,7 @@ 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; @@ -70,9 +71,10 @@ void success() throws Exception { // given Long memberId = getAuthorizationMember().id(); Item item1 = itemRepository.save(morningSantaSkin().build()); - inventoryRepository.save(inventory(memberId, item1)); + Inventory inventory = inventoryRepository.save(inventory(memberId, item1)); + inventory.select(); Item item2 = itemRepository.save(morningKillerSkin().build()); - ItemsResponse expected = ItemMapper.toItemsResponse(List.of(item1), List.of(item2)); + ItemsResponse expected = ItemMapper.toItemsResponse(item1.getId(), List.of(item1), List.of(item2)); // expected String content = mockMvc.perform(get("/items") From e9c8238efee504a6654c36b614080f758be73087 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 17 Nov 2023 14:52:31 +0900 Subject: [PATCH 058/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#101)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 참여중인 방 목록 조회 기능 구현 * feat: 관련 Repository 구현 * test: 참여중인 방 목록 조회 테스트 작성 * refactor: 방 나가기 flush() 수정 * feat: 방 참여 기록 조회 컨트롤러 dto 구현 * feat: 방 참여 기록 조회 기능 구현 * test: 방 참여 기록 조회 서비스 테스트 * test: 방 참여 기록 조회 통합 테스트 * chore: 사용하지 않는 코드 제거 --------- Co-authored-by: ymkim97 --- .../application/room/RoomSearchService.java | 26 +++++++- .../api/application/room/RoomService.java | 2 +- .../application/room/mapper/RoomMapper.java | 18 ++++++ .../ParticipantSearchRepository.java | 12 +++- .../api/dto/room/RoomHistoryResponse.java | 15 +++++ .../api/dto/room/RoomsHistoryResponse.java | 12 ++++ .../api/presentation/RoomController.java | 7 +++ .../room/RoomSearchServiceTest.java | 55 +++++++++++++++-- .../api/presentation/RoomControllerTest.java | 59 +++++++++++++++---- 9 files changed, 184 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/room/RoomHistoryResponse.java create mode 100644 src/main/java/com/moabam/api/dto/room/RoomsHistoryResponse.java diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index fd25b035..ec6fddc9 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -1,6 +1,7 @@ package com.moabam.api.application.room; -import static com.moabam.global.error.model.ErrorMessage.*; +import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.ROOM_DETAILS_ERROR; import java.time.LocalDate; import java.time.Period; @@ -29,6 +30,8 @@ import com.moabam.api.dto.room.MyRoomResponse; import com.moabam.api.dto.room.MyRoomsResponse; 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.global.error.exception.NotFoundException; @@ -69,7 +72,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { public MyRoomsResponse getMyRooms(Long memberId) { LocalDate today = LocalDate.now(); List myRoomResponses = new ArrayList<>(); - List participants = participantSearchRepository.findParticipantsByMemberId(memberId); + List participants = participantSearchRepository.findNotDeletedParticipantsByMemberId(memberId); for (Participant participant : participants) { Room room = participant.getRoom(); @@ -83,6 +86,25 @@ public MyRoomsResponse getMyRooms(Long memberId) { return RoomMapper.toMyRoomsResponse(myRoomResponses); } + public RoomsHistoryResponse getJoinHistory(Long memberId) { + List participants = participantSearchRepository.findAllParticipantsByMemberId(memberId); + List roomHistoryResponses = new ArrayList<>(); + + for (Participant participant : participants) { + if (participant.getRoom() == null) { + roomHistoryResponses.add(RoomMapper.toRoomHistoryResponse(null, + participant.getDeletedRoomTitle(), participant)); + + continue; + } + + roomHistoryResponses.add(RoomMapper.toRoomHistoryResponse(participant.getRoom().getId(), + participant.getRoom().getTitle(), participant)); + } + + return RoomMapper.toRoomsHistoryResponse(roomHistoryResponses); + } + private List getRoutineResponses(Long roomId) { List roomRoutines = routineSearchRepository.findAllByRoomId(roomId); diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 01ec189c..c7c217e3 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -111,6 +111,7 @@ public void exitRoom(Long memberId, Long roomId) { decreaseRoomCount(memberId, room.getRoomType()); participant.removeRoom(); + participantRepository.flush(); participantRepository.delete(participant); if (!participant.isManager()) { @@ -118,7 +119,6 @@ public void exitRoom(Long memberId, Long roomId) { return; } - roomRepository.flush(); roomRepository.delete(room); } 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 index d304bbc1..6435531f 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -3,11 +3,14 @@ import java.time.LocalDate; import java.util.List; +import com.moabam.api.domain.room.Participant; import com.moabam.api.domain.room.Room; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.MyRoomResponse; import com.moabam.api.dto.room.MyRoomsResponse; 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; @@ -68,4 +71,19 @@ public static MyRoomsResponse toMyRoomsResponse(List myRoomRespo .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(); + } } 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 index 9febf65b..d22fdae6 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -44,7 +44,7 @@ public List findParticipantsByRoomId(Long roomId) { .fetch(); } - public List findParticipantsByMemberId(Long memberId) { + public List findNotDeletedParticipantsByMemberId(Long memberId) { return jpaQueryFactory .selectFrom(participant) .join(participant.room, room).fetchJoin() @@ -55,6 +55,16 @@ public List findParticipantsByMemberId(Long memberId) { .fetch(); } + public List findAllParticipantsByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) + .leftJoin(participant.room, room).fetchJoin() + .where( + participant.memberId.eq(memberId) + ) + .fetch(); + } + public List findOtherParticipantsInRoom(Long memberId, Long roomId) { return jpaQueryFactory .selectFrom(participant) 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/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 35db06c7..44f642b6 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -23,6 +23,7 @@ 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.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -105,4 +106,10 @@ public void deportParticipant(@CurrentMember AuthorizationMember authorizationMe public MyRoomsResponse getMyRooms(@CurrentMember AuthorizationMember authorizationMember) { return roomSearchService.getMyRooms(authorizationMember.id()); } + + @GetMapping("/join-history") + @ResponseStatus(HttpStatus.OK) + public RoomsHistoryResponse getJoinHistory(@CurrentMember AuthorizationMember authorizationMember) { + return roomSearchService.getJoinHistory(authorizationMember.id()); + } } diff --git a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java index de0bfffe..ff9663a9 100644 --- a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java @@ -1,9 +1,12 @@ package com.moabam.api.application.room; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.spy; +import static org.mockito.Mockito.when; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -21,6 +24,7 @@ import com.moabam.api.domain.room.repository.ParticipantSearchRepository; import com.moabam.api.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.MyRoomsResponse; +import com.moabam.api.dto.room.RoomsHistoryResponse; import com.moabam.support.fixture.RoomFixture; @ExtendWith(MockitoExtension.class) @@ -54,16 +58,16 @@ void get_my_rooms_success() { Room room2 = spy(RoomFixture.room("아침 - 두 번째 방", RoomType.MORNING, 9)); Room room3 = spy(RoomFixture.room("밤 - 첫 번째 방", RoomType.NIGHT, 22)); - lenient().when(room1.getId()).thenReturn(1L); - lenient().when(room2.getId()).thenReturn(2L); - lenient().when(room3.getId()).thenReturn(3L); + 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.findParticipantsByMemberId(memberId)).willReturn(participants); + given(participantSearchRepository.findNotDeletedParticipantsByMemberId(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); @@ -87,4 +91,43 @@ void get_my_rooms_success() { 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.findAllParticipantsByMemberId(memberId)).willReturn(participants); + + // when + RoomsHistoryResponse response = roomSearchService.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()); + } } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index c2f66f13..f351162d 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1,11 +1,16 @@ 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 static com.moabam.api.domain.room.RoomType.MORNING; +import static com.moabam.api.domain.room.RoomType.NIGHT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; import java.util.ArrayList; @@ -634,12 +639,13 @@ void no_manager_exit_room_success() throws Exception { .andExpect(status().isOk()) .andDo(print()); - participantRepository.flush(); Room findRoom = roomRepository.findById(room.getId()).orElseThrow(); - Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + List deletedParticipant = participantRepository.findAll(); assertThat(findRoom.getCurrentUserCount()).isEqualTo(4); - assertThat(deletedParticipant.getDeletedAt()).isNotNull(); + assertThat(deletedParticipant).hasSize(1); + assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull(); + assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull(); } @DisplayName("방장의 방 나가기 - 방 삭제 성공") @@ -665,10 +671,13 @@ void manager_delete_room_success() throws Exception { .andExpect(status().isOk()) .andDo(print()); - Participant deletedParticipant = participantRepository.findById(participant.getId()).orElseThrow(); + List deletedRoom = roomRepository.findAll(); + List deletedParticipant = participantRepository.findAll(); - assertThat(roomRepository.findById(room.getId())).isEmpty(); - assertThat(deletedParticipant.getDeletedAt()).isNotNull(); + assertThat(deletedRoom).isEmpty(); + assertThat(deletedParticipant).hasSize(1); + assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull(); + assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull(); } @DisplayName("방장이 위임하지 않고 방 나가기 실패") @@ -885,4 +894,30 @@ void get_all_my_rooms_success() throws Exception { .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()); + } } From 5b1a4e8e4dc60768e0c85d6c369dd44ed809d0f6 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Fri, 17 Nov 2023 14:57:47 +0900 Subject: [PATCH 059/185] =?UTF-8?q?feat:=20profile=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20cookie=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20config=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=20(#102)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: profile 환경에 따른 cookie 설정 분리 및 config 업데이트 * test: profile에 따른 쿠키 생성 테스트 --- Dockerfile | 2 +- .../auth/AuthorizationService.java | 11 ++-- .../global/common/util/CookieUtils.java | 33 ----------- .../common/util/cookie/CookieDevUtils.java | 22 +++++++ .../common/util/cookie/CookieProdUtils.java | 22 +++++++ .../common/util/cookie/CookieUtils.java | 22 +++++++ .../com/moabam/global/config/WebConfig.java | 4 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../application/AuthorizationServiceTest.java | 14 +++-- .../global/common/util/CookieMakeTest.java | 57 +++++++++++++++++++ .../support/common/WithFilterSupporter.java | 11 ++-- 14 files changed, 153 insertions(+), 53 deletions(-) delete mode 100644 src/main/java/com/moabam/global/common/util/CookieUtils.java create mode 100644 src/main/java/com/moabam/global/common/util/cookie/CookieDevUtils.java create mode 100644 src/main/java/com/moabam/global/common/util/cookie/CookieProdUtils.java create mode 100644 src/main/java/com/moabam/global/common/util/cookie/CookieUtils.java create mode 100644 src/test/java/com/moabam/global/common/util/CookieMakeTest.java diff --git a/Dockerfile b/Dockerfile index 6e564b38..c7fb704b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,4 +5,4 @@ ENV SPRING_ACTIVE_PROFILES ${SPRING_ACTIVE_PROFILES} COPY build/libs/moabam-server-0.0.1-SNAPSHOT.jar moabam.jar -ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${SPRING_ACTIVE_PROFILES}", "/moabam.jar"] +ENTRYPOINT ["java", "-jar", "-Duser.timezone=Asia/Seoul", "-Dspring.profiles.active=${SPRING_ACTIVE_PROFILES}", "/moabam.jar"] diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 2bdfd695..c2de7a50 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -22,8 +22,8 @@ import com.moabam.api.infrastructure.redis.TokenRepository; import com.moabam.global.auth.model.AuthorizationMember; 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.common.util.cookie.CookieUtils; import com.moabam.global.config.OAuthConfig; import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.BadRequestException; @@ -44,6 +44,7 @@ public class AuthorizationService { private final MemberService memberService; private final JwtProviderService jwtProviderService; private final TokenRepository tokenRepository; + private final CookieUtils cookieUtils; public void redirectToLoginPage(HttpServletResponse httpServletResponse) { String authorizationCodeUri = getAuthorizationCodeUri(); @@ -81,11 +82,11 @@ public void issueServiceToken(HttpServletResponse response, PublicClaim publicCl tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest); response.addCookie( - CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); + cookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())); response.addCookie( - CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire())); + cookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire())); response.addCookie( - CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire())); + cookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire())); } public void validTokenPair(Long id, String oldRefreshToken) { @@ -112,7 +113,7 @@ public void removeToken(HttpServletRequest httpServletRequest, HttpServletRespon Arrays.stream(httpServletRequest.getCookies()) .forEach(cookie -> { if (cookie.getName().contains("token")) { - httpServletResponse.addCookie(CookieUtils.deleteCookie(cookie)); + httpServletResponse.addCookie(cookieUtils.deleteCookie(cookie)); } }); } diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java deleted file mode 100644 index 7cf36469..00000000 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ /dev/null @@ -1,33 +0,0 @@ -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) { - return basic(name, value, expireTime); - } - - public static Cookie typeCookie(String value, long expireTime) { - return basic("token_type", value, expireTime); - } - - public static Cookie deleteCookie(Cookie cookie) { - cookie.setMaxAge(0); - cookie.setPath("/"); - return cookie; - } - - private static Cookie basic(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/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/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index ec0bbd33..63d3bfa6 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -54,7 +55,8 @@ public PathResolver pathResolver() { PathMapper.parsePath("/images/*"), PathMapper.parsePath("/webjars/*"), PathMapper.parsePath("/favicon/*"), - PathMapper.parsePath("/*/icon-*") + PathMapper.parsePath("/*/icon-*"), + PathMapper.parsePath("/serverTime", List.of(HttpMethod.GET)) )) .build(); diff --git a/src/main/resources/config b/src/main/resources/config index 4c6ff3e3..b960de18 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 4c6ff3e36168671367e3f3ff72c659650b79e68f +Subproject commit b960de184fffbc41fd22503c03429a9d3a8ae547 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index be894213..c9cdad6e 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 5801bc5c..b31d0579 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index e637c515..c15a126c 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index 5d9837d7..0cbef779 100644 --- a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -37,7 +37,8 @@ import com.moabam.api.infrastructure.redis.TokenRepository; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.PublicClaim; -import com.moabam.global.common.util.CookieUtils; +import com.moabam.global.common.util.cookie.CookieDevUtils; +import com.moabam.global.common.util.cookie.CookieUtils; import com.moabam.global.config.OAuthConfig; import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.BadRequestException; @@ -68,6 +69,7 @@ class AuthorizationServiceTest { @Mock TokenRepository tokenRepository; + CookieUtils cookieUtils; OAuthConfig oauthConfig; TokenConfig tokenConfig; AuthorizationService noPropertyService; @@ -75,9 +77,11 @@ class AuthorizationServiceTest { @BeforeEach public void initParams() { + cookieUtils = new CookieDevUtils(); tokenConfig = new TokenConfig(null, 100000, 150000, "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"); ReflectionTestUtils.setField(authorizationService, "tokenConfig", tokenConfig); + ReflectionTestUtils.setField(authorizationService, "cookieUtils", cookieUtils); oauthConfig = new OAuthConfig( new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", @@ -93,7 +97,7 @@ public void initParams() { ); noPropertyService = new AuthorizationService(noOAuthConfig, tokenConfig, oAuth2AuthorizationServerRequestService, - memberService, jwtProviderService, tokenRepository); + memberService, jwtProviderService, tokenRepository, cookieUtils); } @DisplayName("인가코드 URI 생성 매퍼 실패") @@ -291,9 +295,9 @@ void error_with_expire_token(@WithMember AuthorizationMember authorizationMember // given MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); httpServletRequest.setCookies( - CookieUtils.tokenCookie("access_token", "value", 100000), - CookieUtils.tokenCookie("refresh_token", "value", 100000), - CookieUtils.typeCookie("Bearer", 100000) + cookieUtils.tokenCookie("access_token", "value", 100000), + cookieUtils.tokenCookie("refresh_token", "value", 100000), + cookieUtils.typeCookie("Bearer", 100000) ); MockHttpServletResponse httpServletResponse = new MockHttpServletResponse(); 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..00addf88 --- /dev/null +++ b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java @@ -0,0 +1,57 @@ +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.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.global.common.util.cookie.CookieDevUtils; +import com.moabam.global.common.util.cookie.CookieProdUtils; +import com.moabam.global.common.util.cookie.CookieUtils; + +import jakarta.servlet.http.Cookie; + +class CookieMakeTest { + + CookieUtils cookieDevUtils; + CookieUtils cookieProdUtils; + + @BeforeEach + void setUp() { + cookieDevUtils = new CookieDevUtils(); + cookieProdUtils = new CookieProdUtils(); + } + + @DisplayName("prod환경에서 cookie 생성 테스트") + @Test + void prodUtilsTest() { + // Given + Cookie cookie = cookieProdUtils.tokenCookie("access_token", "value", 10000); + + // When + Then + assertAll( + () -> assertThat(cookie.getSecure()).isTrue(), + () -> assertThat(cookie.getSecure()).isTrue(), + () -> assertThat(cookie.getPath()).isEqualTo("/"), + () -> assertThat(cookie.getMaxAge()).isEqualTo(10000) + ); + } + + @DisplayName("dev환경에서 cookie 생성 테스트") + @Test + void devUtilsTest() { + // Given + Cookie cookie = cookieDevUtils.tokenCookie("access_token", "value", 10000); + + // 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") + ); + } +} diff --git a/src/test/java/com/moabam/support/common/WithFilterSupporter.java b/src/test/java/com/moabam/support/common/WithFilterSupporter.java index 9648687f..69b5f114 100644 --- a/src/test/java/com/moabam/support/common/WithFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithFilterSupporter.java @@ -13,7 +13,7 @@ import org.springframework.web.context.WebApplicationContext; import com.moabam.api.application.auth.JwtProviderService; -import com.moabam.global.common.util.CookieUtils; +import com.moabam.global.common.util.cookie.CookieUtils; import com.moabam.global.config.TokenConfig; import com.moabam.support.fixture.PublicClaimFixture; @@ -32,6 +32,9 @@ public class WithFilterSupporter { @Autowired TokenConfig tokenConfig; + @Autowired + CookieUtils cookieUtils; + protected MockMvc mockMvc; @BeforeEach @@ -39,11 +42,11 @@ 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", + .cookie(cookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire())) + .cookie(cookieUtils.tokenCookie("access_token", jwtProviderService.provideAccessToken(PublicClaimFixture.publicClaim()), tokenConfig.getRefreshExpire())) - .cookie(CookieUtils.tokenCookie("refresh_token", + .cookie(cookieUtils.tokenCookie("refresh_token", jwtProviderService.provideRefreshToken(), tokenConfig.getRefreshExpire()))) .build(); From f12569e924685510d93987ea3fb74c59ace4ff34 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 17 Nov 2023 15:15:14 +0900 Subject: [PATCH 060/185] hotfix: config update --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index b960de18..2b721299 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit b960de184fffbc41fd22503c03429a9d3a8ae547 +Subproject commit 2b7212992e491c6d222f0b6a9b9289525be07008 From a4c2f2fb3c5e1f7bab74cb49037c44853471c801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:12:04 +0900 Subject: [PATCH 061/185] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0,=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=B0=8F=20=ED=81=B4=EB=9E=98=EC=8A=A4?= =?UTF-8?q?=EB=AA=85=20=EB=B3=80=EA=B2=BD=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 쿠폰 및 토큰 패키지 및 클래스명 변경 * refactor: 알림 패키지 및 클래스명 변경, Fcm 로직 분리 --- .../auth/AuthorizationService.java | 2 +- .../notification/NotificationMapper.java | 13 +----- .../notification/NotificationService.java | 31 ++++++------- .../auth/repository}/TokenRepository.java | 15 ++++--- .../repository}/NotificationRepository.java | 14 +++--- .../api/infrastructure/fcm/FcmService.java | 26 +++++++++++ ...pository.java => HashRedisRepository.java} | 7 +-- .../redis/StringRedisRepository.java | 12 +++--- .../global/common/util/GlobalConstant.java | 2 - .../com/moabam/global/config/FcmConfig.java | 4 +- .../com/moabam/global/config/RedisConfig.java | 14 +----- src/main/resources/static/docs/coupon.html | 10 ++--- .../resources/static/docs/notification.html | 2 +- .../application/AuthorizationServiceTest.java | 2 +- .../notification/NotificationServiceTest.java | 13 +++--- .../NotificationRepositoryTest.java | 4 +- .../repository/TokenRepostiroyTest.java | 10 ++--- .../infrastructure/fcm/FcmServiceTest.java | 43 +++++++++++++++++++ ...Test.java => HashRedisRepositoryTest.java} | 18 ++++---- .../NotificationControllerTest.java | 5 ++- 20 files changed, 148 insertions(+), 99 deletions(-) rename src/main/java/com/moabam/api/{infrastructure/redis => domain/auth/repository}/TokenRepository.java (59%) rename src/main/java/com/moabam/api/{infrastructure/redis => domain/notification/repository}/NotificationRepository.java (80%) create mode 100644 src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java rename src/main/java/com/moabam/api/infrastructure/redis/{HashTemplateRepository.java => HashRedisRepository.java} (85%) rename src/test/java/com/moabam/api/{infrastructure/redis => domain/notification/repository}/NotificationRepositoryTest.java (97%) create mode 100644 src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java rename src/test/java/com/moabam/api/infrastructure/redis/{HashTemplateRepositoryTest.java => HashRedisRepositoryTest.java} (79%) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index c2de7a50..df6e2851 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -12,6 +12,7 @@ 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.dto.auth.AuthorizationCodeRequest; import com.moabam.api.dto.auth.AuthorizationCodeResponse; import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; @@ -19,7 +20,6 @@ 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.redis.TokenRepository; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.common.util.GlobalConstant; diff --git a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java index b18156e2..792821e1 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java @@ -13,20 +13,11 @@ public final class NotificationMapper { private static final String NOTIFICATION_TITLE = "모아밤"; - private static final String KNOCK_BODY = "님이 콕 찔렀습니다."; - private static final String CERTIFY_TIME_BODY = "방 인증 시간입니다."; - public static Notification toKnockNotificationEntity(String nickname) { + public static Notification toNotification(String body) { return Notification.builder() .setTitle(NOTIFICATION_TITLE) - .setBody(nickname + KNOCK_BODY) - .build(); - } - - public static Notification toCertifyAuthNotificationEntity(String title) { - return Notification.builder() - .setTitle(NOTIFICATION_TITLE) - .setBody(title + CERTIFY_TIME_BODY) + .setBody(body) .build(); } diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 71e094f6..12722c9d 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -12,14 +12,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.Message; -import com.google.firebase.messaging.Notification; 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.dto.notification.KnockNotificationStatusResponse; -import com.moabam.api.infrastructure.redis.NotificationRepository; +import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -32,8 +30,12 @@ @Transactional(readOnly = true) public class NotificationService { + private static final String KNOCK_BODY = "%s님이 콕 찔렀습니다."; + private static final String CERTIFY_TIME_BODY = "%s방 인증 시간입니다."; + private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + + private final FcmService fcmService; private final RoomService roomService; - private final FirebaseMessaging firebaseMessaging; private final NotificationRepository notificationRepository; private final ParticipantSearchRepository participantSearchRepository; @@ -45,8 +47,9 @@ public void sendKnockNotification(AuthorizationMember member, Long targetId, Lon validateConflictKnockNotification(knockKey); validateFcmToken(targetId); - Notification notification = NotificationMapper.toKnockNotificationEntity(member.nickname()); - sendAsyncFcm(targetId, notification); + String fcmToken = notificationRepository.findFcmTokenByMemberId(targetId); + String notificationBody = String.format(KNOCK_BODY, member.nickname()); + fcmService.sendAsyncFcm(fcmToken, notificationBody); notificationRepository.saveKnockNotification(knockKey); } @@ -57,8 +60,9 @@ public void sendCertificationTimeNotification() { participants.parallelStream().forEach(participant -> { String roomTitle = participant.getRoom().getTitle(); - Notification notification = NotificationMapper.toCertifyAuthNotificationEntity(roomTitle); - sendAsyncFcm(participant.getMemberId(), notification); + String fcmToken = notificationRepository.findFcmTokenByMemberId(participant.getMemberId()); + String notificationBody = String.format(CERTIFY_TIME_BODY, roomTitle); + fcmService.sendAsyncFcm(fcmToken, notificationBody); }); } @@ -80,15 +84,6 @@ public KnockNotificationStatusResponse checkMyKnockNotificationStatusInRoom(Auth .toKnockNotificationStatusResponse(knockNotificationStatus.get(true), knockNotificationStatus.get(false)); } - private void sendAsyncFcm(Long fcmTokenKey, Notification notification) { - String fcmToken = notificationRepository.findFcmTokenByMemberId(fcmTokenKey); - - if (fcmToken != null) { - Message message = NotificationMapper.toMessageEntity(notification, fcmToken); - firebaseMessaging.sendAsync(message); - } - } - private void validateConflictKnockNotification(String knockKey) { if (notificationRepository.existsByKey(knockKey)) { throw new ConflictException(ErrorMessage.CONFLICT_KNOCK); diff --git a/src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java b/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java similarity index 59% rename from src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java rename to src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java index f714d269..3286d0e4 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/TokenRepository.java +++ b/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java @@ -1,4 +1,4 @@ -package com.moabam.api.infrastructure.redis; +package com.moabam.api.domain.auth.repository; import java.time.Duration; @@ -6,33 +6,34 @@ import org.springframework.stereotype.Repository; 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 HashTemplateRepository hashTemplateRepository; + private final HashRedisRepository hashRedisRepository; @Autowired - public TokenRepository(HashTemplateRepository hashTemplateRepository) { - this.hashTemplateRepository = hashTemplateRepository; + public TokenRepository(HashRedisRepository hashRedisRepository) { + this.hashRedisRepository = hashRedisRepository; } public void saveToken(Long memberId, TokenSaveValue tokenSaveRequest) { String tokenKey = parseTokenKey(memberId); - hashTemplateRepository.save(tokenKey, tokenSaveRequest, Duration.ofDays(EXPIRE_DAYS)); + hashRedisRepository.save(tokenKey, tokenSaveRequest, Duration.ofDays(EXPIRE_DAYS)); } public TokenSaveValue getTokenSaveValue(Long memberId) { String tokenKey = parseTokenKey(memberId); - return (TokenSaveValue)hashTemplateRepository.get(tokenKey); + return (TokenSaveValue)hashRedisRepository.get(tokenKey); } public void delete(Long memberId) { String tokenKey = parseTokenKey(memberId); - hashTemplateRepository.delete(tokenKey); + hashRedisRepository.delete(tokenKey); } private String parseTokenKey(Long memberId) { diff --git a/src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java similarity index 80% rename from src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java rename to src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java index 70b25df9..df41d185 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java @@ -1,4 +1,4 @@ -package com.moabam.api.infrastructure.redis; +package com.moabam.api.domain.notification.repository; import static com.moabam.global.common.util.GlobalConstant.*; import static java.util.Objects.*; @@ -7,6 +7,8 @@ import org.springframework.stereotype.Repository; +import com.moabam.api.infrastructure.redis.StringRedisRepository; + import lombok.RequiredArgsConstructor; @Repository @@ -19,17 +21,17 @@ public class NotificationRepository { private final StringRedisRepository stringRedisRepository; // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. Front와 상의 후 삭제예정 - public void saveFcmToken(Long key, String value) { + public void saveFcmToken(Long memberId, String fcmToken) { stringRedisRepository.save( - String.valueOf(requireNonNull(key)), - requireNonNull(value), + String.valueOf(requireNonNull(memberId)), + requireNonNull(fcmToken), Duration.ofDays(EXPIRE_FCM_TOKEN) ); } - public void saveKnockNotification(String key) { + public void saveKnockNotification(String knockKey) { stringRedisRepository.save( - requireNonNull(key), + requireNonNull(knockKey), BLANK, Duration.ofHours(EXPIRE_KNOCK) ); 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..e995688c --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -0,0 +1,26 @@ +package com.moabam.api.infrastructure.fcm; + +import org.springframework.stereotype.Service; + +import com.google.firebase.messaging.FirebaseMessaging; +import com.google.firebase.messaging.Message; +import com.google.firebase.messaging.Notification; +import com.moabam.api.application.notification.NotificationMapper; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class FcmService { + + private final FirebaseMessaging firebaseMessaging; + + public void sendAsyncFcm(String fcmToken, String notificationBody) { + Notification notification = NotificationMapper.toNotification(notificationBody); + + if (fcmToken != null) { + Message message = NotificationMapper.toMessageEntity(notification, fcmToken); + firebaseMessaging.sendAsync(message); + } + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java similarity index 85% rename from src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java rename to src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java index 12b1d291..0af79bc2 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/HashTemplateRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/HashRedisRepository.java @@ -13,18 +13,19 @@ import com.moabam.global.error.model.ErrorMessage; @Repository -public class HashTemplateRepository { +public class HashRedisRepository { private final RedisTemplate redisTemplate; private final HashOperations hashOperations; private final Jackson2HashMapper hashMapper; - public HashTemplateRepository(RedisTemplate redisTemplate) { + 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); @@ -37,7 +38,7 @@ public void delete(String key) { public Object get(String key) { Map memberToken = hashOperations.entries(key); - if (memberToken.size() == 0) { + if (memberToken.isEmpty()) { throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); } diff --git a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java index 9d877dae..0b55a397 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java @@ -2,7 +2,7 @@ import java.time.Duration; -import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Repository; import lombok.RequiredArgsConstructor; @@ -11,25 +11,25 @@ @RequiredArgsConstructor public class StringRedisRepository { - private final StringRedisTemplate stringRedisTemplate; + private final RedisTemplate redisTemplate; public void save(String key, String value, Duration timeout) { - stringRedisTemplate + redisTemplate .opsForValue() .set(key, value, timeout); } public void delete(String key) { - stringRedisTemplate.delete(key); + redisTemplate.delete(key); } public String get(String key) { - return stringRedisTemplate + return redisTemplate .opsForValue() .get(key); } public Boolean hasKey(String key) { - return stringRedisTemplate.hasKey(key); + return redisTemplate.hasKey(key); } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 8f43ec67..333f0cd5 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -14,8 +14,6 @@ public class GlobalConstant { public static final String SPACE = " "; public static final int ONE_HOUR = 1; public static final int HOURS_IN_A_DAY = 24; - public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; - public static final String FIREBASE_PATH = "config/moabam-firebase.json"; public static final int LEVEL_DIVISOR = 10; } diff --git a/src/main/java/com/moabam/global/config/FcmConfig.java b/src/main/java/com/moabam/global/config/FcmConfig.java index 678248b5..115e6091 100644 --- a/src/main/java/com/moabam/global/config/FcmConfig.java +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -1,7 +1,5 @@ package com.moabam.global.config; -import static com.moabam.global.common.util.GlobalConstant.*; - import java.io.IOException; import java.io.InputStream; @@ -24,6 +22,8 @@ @EnableScheduling 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()) { diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index 3c154692..fef4a99e 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -7,7 +7,6 @@ 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.core.StringRedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -26,20 +25,11 @@ public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(redisHost, redisPort); } - @Bean - public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { - StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); - stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); - stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); - stringRedisTemplate.setConnectionFactory(redisConnectionFactory); - - return stringRedisTemplate; - } - @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); redisTemplate.setConnectionFactory(redisConnectionFactory); return redisTemplate; diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index c9cdad6e..432d567d 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 200
+Content-Length: 192
 Host: localhost:8080
 
 {
@@ -483,7 +483,7 @@ 

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 64 +Content-Length: 62 { "message" : "쿠폰의 이름이 중복되었습니다." @@ -514,7 +514,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 58 +Content-Length: 56 { "message" : "존재하지 않는 쿠폰입니다." @@ -546,7 +546,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 58 +Content-Length: 56 { "message" : "존재하지 않는 쿠폰입니다." @@ -569,7 +569,7 @@

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 88
+Content-Length: 84
 Host: localhost:8080
 
 {
diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html
index c15a126c..d84209a5 100644
--- a/src/main/resources/static/docs/notification.html
+++ b/src/main/resources/static/docs/notification.html
@@ -473,7 +473,7 @@ 

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "이미 콕 알림을 보낸 대상입니다." diff --git a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java index 0cbef779..77cacbc3 100644 --- a/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/AuthorizationServiceTest.java @@ -28,13 +28,13 @@ import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; 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.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.redis.TokenRepository; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.common.util.cookie.CookieDevUtils; diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index f395e6c8..1dc9b4bc 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -14,13 +14,12 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.google.firebase.messaging.FirebaseMessaging; -import com.google.firebase.messaging.Message; 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.dto.notification.KnockNotificationStatusResponse; -import com.moabam.api.infrastructure.redis.NotificationRepository; +import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.error.exception.ConflictException; @@ -39,7 +38,7 @@ class NotificationServiceTest { private RoomService roomService; @Mock - private FirebaseMessaging firebaseMessaging; + private FcmService fcmService; @Mock private NotificationRepository notificationRepository; @@ -63,7 +62,7 @@ void notificationService_sendKnockNotification() { notificationService.sendKnockNotification(member, 2L, 1L); // Then - verify(firebaseMessaging).sendAsync(any(Message.class)); + verify(fcmService).sendAsyncFcm(any(String.class), any(String.class)); verify(notificationRepository).saveKnockNotification(any(String.class)); } @@ -125,7 +124,7 @@ void notificationService_sendCertificationTimeNotification(List par notificationService.sendCertificationTimeNotification(); // Then - verify(firebaseMessaging, times(3)).sendAsync(any(Message.class)); + verify(fcmService, times(3)).sendAsyncFcm(any(String.class), any(String.class)); } @WithMember @@ -141,7 +140,7 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L notificationService.sendCertificationTimeNotification(); // Then - verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); + verify(fcmService, times(0)).sendAsyncFcm(any(String.class), any(String.class)); } @WithMember diff --git a/src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java similarity index 97% rename from src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java rename to src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java index ae30de5d..6822b175 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.infrastructure.redis; +package com.moabam.api.domain.notification.repository; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; @@ -12,6 +12,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.infrastructure.redis.StringRedisRepository; + @ExtendWith(MockitoExtension.class) class NotificationRepositoryTest { diff --git a/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java index 5f8a473f..3b5d6863 100644 --- a/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java +++ b/src/test/java/com/moabam/api/domain/repository/TokenRepostiroyTest.java @@ -14,9 +14,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.domain.auth.repository.TokenRepository; import com.moabam.api.dto.auth.TokenSaveValue; -import com.moabam.api.infrastructure.redis.HashTemplateRepository; -import com.moabam.api.infrastructure.redis.TokenRepository; +import com.moabam.api.infrastructure.redis.HashRedisRepository; import com.moabam.support.fixture.TokenSaveValueFixture; @ExtendWith(MockitoExtension.class) @@ -26,13 +26,13 @@ class TokenRepostiroyTest { TokenRepository tokenRepository; @Mock - HashTemplateRepository hashTemplateRepository; + HashRedisRepository hashRedisRepository; @DisplayName("토큰 저장 성공") @Test void save_token_suceess() { // Given - willDoNothing().given(hashTemplateRepository).save(any(), any(TokenSaveValue.class), any(Duration.class)); + willDoNothing().given(hashRedisRepository).save(any(), any(TokenSaveValue.class), any(Duration.class)); // When + Then Assertions.assertThatNoException() @@ -44,7 +44,7 @@ void save_token_suceess() { void token_get_success() { // given willReturn(TokenSaveValueFixture.tokenSaveValue("token")) - .given(hashTemplateRepository).get(anyString()); + .given(hashRedisRepository).get(anyString()); // when TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); 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..c7a331e3 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -0,0 +1,43 @@ +package com.moabam.api.infrastructure.fcm; + +import static org.mockito.Mockito.*; + +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; + +@SpringBootTest(classes = {FcmConfig.class, FcmService.class}) +class FcmServiceTest { + + @Autowired + private FcmService fcmService; + + @MockBean + private FirebaseMessaging firebaseMessaging; + + @DisplayName("비동기 FCM 알림을 성공적으로 보낸다. - Void") + @Test + void fcmService_sendAsyncFcm() { + // When + fcmService.sendAsyncFcm("FCM-TOKEN", "알림"); + + // Then + verify(firebaseMessaging).sendAsync(any(Message.class)); + } + + @DisplayName("FCM 토큰이 null이여서 비동기 FCM 알림을 보내지. - Void") + @Test + void fcmService_sendAsyncFcm_null() { + // When + fcmService.sendAsyncFcm(null, "알림"); + + // Then + verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); + } +} diff --git a/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java similarity index 79% rename from src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java rename to src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java index 9998b667..ab0cd833 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/HashTemplateRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/HashRedisRepositoryTest.java @@ -19,11 +19,11 @@ import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.TokenSaveValueFixture; -@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, HashTemplateRepository.class}) -class HashTemplateRepositoryTest { +@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, HashRedisRepository.class}) +class HashRedisRepositoryTest { @Autowired - private HashTemplateRepository hashTemplateRepository; + private HashRedisRepository hashRedisRepository; String key = "auth_123"; String token = "token"; @@ -33,19 +33,19 @@ class HashTemplateRepositoryTest { @BeforeEach void setUp() { - hashTemplateRepository.save(key, (Object)tokenSaveValue, duration); + hashRedisRepository.save(key, (Object)tokenSaveValue, duration); } @AfterEach void delete() { - hashTemplateRepository.delete(key); + hashRedisRepository.delete(key); } @DisplayName("레디스에 hash 저장 성공") @Test void hashTemplate_repository_save_success() { // Given + When - TokenSaveValue object = (TokenSaveValue)hashTemplateRepository.get(key); + TokenSaveValue object = (TokenSaveValue)hashRedisRepository.get(key); // Then assertAll( @@ -58,10 +58,10 @@ void hashTemplate_repository_save_success() { @Test void delete_and_get_null() { // Given - hashTemplateRepository.delete(key); + hashRedisRepository.delete(key); // When + Then - assertThatThrownBy(() -> hashTemplateRepository.get(key)) + assertThatThrownBy(() -> hashRedisRepository.get(key)) .isInstanceOf(UnauthorizedException.class) .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); } @@ -70,7 +70,7 @@ void delete_and_get_null() { @Test void valid_token_failby_token_is_null() { // Given + When + Then - assertThatThrownBy(() -> hashTemplateRepository.get("0")) + assertThatThrownBy(() -> hashRedisRepository.get("0")) .isInstanceOf(UnauthorizedException.class) .hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 2b2f8bda..86cd81f5 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -1,6 +1,5 @@ package com.moabam.api.presentation; -import static com.moabam.global.common.util.GlobalConstant.*; import static org.mockito.BDDMockito.*; import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.*; import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.*; @@ -25,9 +24,9 @@ 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.redis.NotificationRepository; import com.moabam.api.infrastructure.redis.StringRedisRepository; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; @@ -42,6 +41,8 @@ @AutoConfigureRestDocs class NotificationControllerTest extends WithoutFilterSupporter { + private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + @Autowired private MockMvc mockMvc; From 8d16e9d96b11861bc50415427c89d97b51f737a5 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 20 Nov 2023 14:20:42 +0900 Subject: [PATCH 062/185] =?UTF-8?q?feat:=20=EB=B2=8C=EB=A0=88=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B5=AC=EB=A7=A4=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 결제 엔티티 생성 * feat: 벌레 상품 구매 API 구현 * test: 벌레 상품 구매 통합 테스트 * test: 벌레 상품 구매 서비스 테스트 * test: 결제 쿠폰 적용 테스트 * test: 주문 생성 및 금액 할인 테스트 * test: 벌레 사용 및 증가 로직 검증 방식 수정 * chore: config 업데이트 * fix: 상품 구매 Response에 주문 id 제거 * feat: 상품 구매 Response에 결제 id 추가 * fix: Transactional 적용 --- .../api/application/bug/BugService.java | 33 +++++++++ .../application/payment/PaymentMapper.java | 25 +++++++ .../application/product/ProductMapper.java | 10 +++ .../com/moabam/api/domain/payment/Order.java | 50 +++++++++++++ .../moabam/api/domain/payment/Payment.java | 73 +++++++++++++++++++ .../api/domain/payment/PaymentStatus.java | 10 +++ .../payment/repository/PaymentRepository.java | 9 +++ .../dto/product/PurchaseProductRequest.java | 9 +++ .../dto/product/PurchaseProductResponse.java | 12 +++ .../api/presentation/BugController.java | 13 ++++ .../global/error/model/ErrorMessage.java | 3 + src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../api/application/bug/BugServiceTest.java | 24 ++++++ .../com/moabam/api/domain/bug/BugTest.java | 14 ++-- .../moabam/api/domain/payment/OrderTest.java | 57 +++++++++++++++ .../api/domain/payment/PaymentTest.java | 28 +++++++ .../api/domain/product/ProductTest.java | 4 +- .../api/presentation/BugControllerTest.java | 63 +++++++++++++++- .../moabam/support/fixture/CouponFixture.java | 17 +++++ .../support/fixture/PaymentFixture.java | 24 ++++++ .../support/fixture/ProductFixture.java | 2 +- 23 files changed, 472 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/payment/PaymentMapper.java create mode 100644 src/main/java/com/moabam/api/domain/payment/Order.java create mode 100644 src/main/java/com/moabam/api/domain/payment/Payment.java create mode 100644 src/main/java/com/moabam/api/domain/payment/PaymentStatus.java create mode 100644 src/main/java/com/moabam/api/domain/payment/repository/PaymentRepository.java create mode 100644 src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java create mode 100644 src/main/java/com/moabam/api/dto/product/PurchaseProductResponse.java create mode 100644 src/test/java/com/moabam/api/domain/payment/OrderTest.java create mode 100644 src/test/java/com/moabam/api/domain/payment/PaymentTest.java create mode 100644 src/test/java/com/moabam/support/fixture/PaymentFixture.java diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 6093f51d..c7de536b 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -3,6 +3,8 @@ import static com.moabam.api.domain.bug.BugActionType.*; import static com.moabam.api.domain.bug.BugType.*; 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; @@ -10,17 +12,25 @@ 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.BugHistory; import com.moabam.api.domain.bug.BugType; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.repository.CouponRepository; 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.bug.TodayBugResponse; 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.ClockHolder; +import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -32,6 +42,8 @@ public class BugService { private final MemberService memberService; private final BugHistorySearchRepository bugHistorySearchRepository; private final ProductRepository productRepository; + private final PaymentRepository paymentRepository; + private final CouponRepository couponRepository; private final ClockHolder clockHolder; public BugResponse getBug(Long memberId) { @@ -54,10 +66,31 @@ public ProductsResponse getBugProducts() { return ProductMapper.toProductsResponse(products); } + @Transactional + public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, PurchaseProductRequest request) { + Product product = getById(productId); + Payment payment = PaymentMapper.toEntity(memberId, product); + + if (!isNull(request.couponId())) { + // TODO: (임시) CouponWallet 에 존재하는 할인 쿠폰인지 확인 @홍혁준 + Coupon coupon = couponRepository.findById(request.couponId()) + .orElseThrow(() -> new NotFoundException(NOT_FOUND_COUPON)); + payment.applyCoupon(coupon); + } + paymentRepository.save(payment); + + return ProductMapper.toPurchaseProductResponse(payment); + } + private int calculateBugQuantity(List bugHistory, BugType bugType) { return bugHistory.stream() .filter(history -> bugType.equals(history.getBugType())) .mapToInt(BugHistory::getQuantity) .sum(); } + + private Product getById(Long productId) { + return productRepository.findById(productId) + .orElseThrow(() -> new NotFoundException(PRODUCT_NOT_FOUND)); + } } 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..c68b78c6 --- /dev/null +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -0,0 +1,25 @@ +package com.moabam.api.application.payment; + +import com.moabam.api.domain.payment.Order; +import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.product.Product; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PaymentMapper { + + public static Payment toEntity(Long memberId, Product product) { + Order order = Order.builder() + .name(product.getName()) + .amount(product.getPrice()) + .build(); + + return Payment.builder() + .memberId(memberId) + .product(product) + .order(order) + .build(); + } +} diff --git a/src/main/java/com/moabam/api/application/product/ProductMapper.java b/src/main/java/com/moabam/api/application/product/ProductMapper.java index 6207cfc0..45434316 100644 --- a/src/main/java/com/moabam/api/application/product/ProductMapper.java +++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java @@ -2,9 +2,11 @@ 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; @@ -28,4 +30,12 @@ public static ProductsResponse toProductsResponse(List products) { .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.getOrder().getAmount()) + .build(); + } } 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..98f78037 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/Order.java @@ -0,0 +1,50 @@ +package com.moabam.api.domain.payment; + +import static com.moabam.global.error.model.ErrorMessage.*; +import static java.lang.Math.*; +import static java.util.Objects.*; + +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 Order { + + private static final int MIN_AMOUNT = 0; + + @Column(name = "order_id") + private String id; + + @Column(name = "order_name", nullable = false) + private String name; + + @Column(name = "amount", nullable = false) + private int amount; + + @Builder + private Order(String id, String name, int amount) { + this.id = id; + this.name = requireNonNull(name); + this.amount = validateAmount(amount); + } + + private int validateAmount(int amount) { + if (amount < MIN_AMOUNT) { + throw new BadRequestException(INVALID_ORDER_AMOUNT); + } + + return amount; + } + + public void discountAmount(int price) { + this.amount = max(MIN_AMOUNT, amount - price); + } +} 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..8f5cfff0 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -0,0 +1,73 @@ +package com.moabam.api.domain.payment; + +import static java.util.Objects.*; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.product.Product; +import com.moabam.global.common.entity.BaseTimeEntity; + +import jakarta.persistence.Column; +import jakarta.persistence.Embedded; +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 = "payment") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Payment 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 = "product_id", updatable = false, nullable = false) + private Product product; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "coupon_id") + private Coupon coupon; + + @Embedded + private Order order; + + @Column(name = "payment_key") + private String paymentKey; + + @Enumerated(value = EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Builder + public Payment(Long memberId, Product product, Coupon coupon, Order order, String paymentKey, + PaymentStatus status) { + this.memberId = requireNonNull(memberId); + this.product = requireNonNull(product); + this.coupon = coupon; + this.order = requireNonNull(order); + this.paymentKey = paymentKey; + this.status = requireNonNullElse(status, PaymentStatus.REQUEST); + } + + public void applyCoupon(Coupon coupon) { + this.order.discountAmount(coupon.getPoint()); + this.coupon = coupon; + } +} 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..37806cb3 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java @@ -0,0 +1,10 @@ +package com.moabam.api.domain.payment; + +public enum PaymentStatus { + + // 임시 + REQUEST, + DONE, + FAIL, + REFUND; +} 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/dto/product/PurchaseProductRequest.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java new file mode 100644 index 00000000..394bbeba --- /dev/null +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java @@ -0,0 +1,9 @@ +package com.moabam.api.dto.product; + +import javax.annotation.Nullable; + +public record PurchaseProductRequest( + @Nullable Long couponId +) { + +} 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/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 95de7f5b..287ce4b8 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -2,6 +2,9 @@ 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; @@ -10,6 +13,8 @@ import com.moabam.api.dto.bug.BugResponse; import com.moabam.api.dto.bug.TodayBugResponse; 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.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -39,4 +44,12 @@ public TodayBugResponse getTodayBug(@CurrentMember AuthorizationMember member) { public ProductsResponse getBugProducts() { return bugService.getBugProducts(); } + + @PostMapping("/products/{productId}/purchase") + @ResponseStatus(HttpStatus.OK) + public PurchaseProductResponse purchaseBugProduct(@CurrentMember AuthorizationMember member, + @PathVariable Long productId, + @RequestBody PurchaseProductRequest request) { + return bugService.purchaseBugProduct(member.id(), productId, request); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 90c493c0..ad826461 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -47,6 +47,9 @@ public enum ErrorMessage { INVALID_PRICE("가격은 0 이상이어야 합니다."), INVALID_QUANTITY("수량은 1 이상이어야 합니다."), INVALID_LEVEL("레벨은 1 이상이어야 합니다."), + INVALID_ORDER_AMOUNT("주문 금액은 0 이상이어야 합니다."), + + PRODUCT_NOT_FOUND("존재하지 않는 상품입니다."), FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 432d567d..dc7b0eb3 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -627,7 +627,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index b31d0579..3b245d88 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index d84209a5..0e9f868c 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index 6d65a2e3..06e9a1c3 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -7,8 +7,10 @@ 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; @@ -23,7 +25,9 @@ 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.global.common.util.StreamUtils; +import com.moabam.global.error.exception.NotFoundException; @ExtendWith(MockitoExtension.class) class BugServiceTest { @@ -71,4 +75,24 @@ void get_bug_products_success() { assertThat(response.products()).hasSize(2); assertThat(productNames).containsExactly(BUG_PRODUCT_NAME, BUG_PRODUCT_NAME); } + + @DisplayName("벌레 상품을 구매한다.") + @Nested + class PurchaseBugProduct { + + @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("존재하지 않는 상품입니다."); + } + } } diff --git a/src/test/java/com/moabam/api/domain/bug/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java index f2720165..7e1981f0 100644 --- a/src/test/java/com/moabam/api/domain/bug/BugTest.java +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -2,7 +2,6 @@ import static com.moabam.support.fixture.BugFixture.*; 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; @@ -42,8 +41,11 @@ void success() { // given Bug bug = bug(); - // when, then - assertDoesNotThrow(() -> bug.use(BugType.MORNING, 5)); + // when + bug.use(BugType.MORNING, 5); + + // then + assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG - 5); } @DisplayName("벌레 개수가 부족하면 사용할 수 없다.") @@ -71,8 +73,8 @@ void increase_bug_success() { bug.increaseBug(BugType.GOLDEN, 5); // then - assertThat(bug.getMorningBug()).isEqualTo(15); - assertThat(bug.getNightBug()).isEqualTo(25); - assertThat(bug.getGoldenBug()).isEqualTo(35); + assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG + 5); + assertThat(bug.getNightBug()).isEqualTo(NIGHT_BUG + 5); + assertThat(bug.getGoldenBug()).isEqualTo(GOLDEN_BUG + 5); } } 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..bf075c4f --- /dev/null +++ b/src/test/java/com/moabam/api/domain/payment/OrderTest.java @@ -0,0 +1,57 @@ +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.Nested; +import org.junit.jupiter.api.Test; + +import com.moabam.global.error.exception.BadRequestException; + +class OrderTest { + + @DisplayName("금액이 음수이면 예외가 발생한다.") + @Test + void validate_bug_count_exception() { + Order.OrderBuilder orderBuilder = Order.builder() + .name(BUG_PRODUCT_NAME) + .amount(-1000); + + assertThatThrownBy(orderBuilder::build) + .isInstanceOf(BadRequestException.class) + .hasMessage("주문 금액은 0 이상이어야 합니다."); + } + + @DisplayName("금액을 할인한다.") + @Nested + class Use { + + @DisplayName("성공한다.") + @Test + void success() { + // given + Order order = order(); + + // when + order.discountAmount(1000); + + // then + assertThat(order.getAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); + } + + @DisplayName("할인 금액이 주문 금액보다 크면 0으로 처리한다.") + @Test + void discount_amount_greater_than_order_amount() { + // given + Order order = order(); + + // when + order.discountAmount(10000); + + // then + assertThat(order.getAmount()).isZero(); + } + } +} 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..78a6eadd --- /dev/null +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -0,0 +1,28 @@ +package com.moabam.api.domain.payment; + +import static com.moabam.support.fixture.CouponFixture.*; +import static com.moabam.support.fixture.PaymentFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.moabam.api.domain.coupon.Coupon; + +class PaymentTest { + + @DisplayName("쿠폰을 적용한다.") + @Test + void apply_coupon() { + // given + Payment payment = bugProductPayment(); + Coupon coupon = discount1000Coupon(); + + // when + payment.applyCoupon(coupon); + + // then + assertThat(payment.getOrder().getAmount()).isEqualTo(2000); + assertThat(payment.getCoupon()).isEqualTo(coupon); + } +} diff --git a/src/test/java/com/moabam/api/domain/product/ProductTest.java b/src/test/java/com/moabam/api/domain/product/ProductTest.java index 4e905697..4aa61a90 100644 --- a/src/test/java/com/moabam/api/domain/product/ProductTest.java +++ b/src/test/java/com/moabam/api/domain/product/ProductTest.java @@ -13,7 +13,7 @@ class ProductTest { @Test void validate_price_exception() { Product.ProductBuilder productBuilder = Product.builder() - .name("X10") + .name("황금벌레 10") .price(-10); assertThatThrownBy(productBuilder::build) @@ -25,7 +25,7 @@ void validate_price_exception() { @Test void validate_quantity_exception() { Product.ProductBuilder productBuilder = Product.builder() - .name("X10") + .name("황금벌레 10") .price(1000) .quantity(-1); diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index a2c66b1e..cc754567 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -3,6 +3,7 @@ import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; import static com.moabam.support.fixture.BugFixture.*; import static com.moabam.support.fixture.BugHistoryFixture.*; +import static com.moabam.support.fixture.CouponFixture.*; import static com.moabam.support.fixture.MemberFixture.*; import static com.moabam.support.fixture.ProductFixture.*; import static java.nio.charset.StandardCharsets.*; @@ -16,6 +17,7 @@ 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; @@ -30,11 +32,15 @@ import com.moabam.api.application.product.ProductMapper; import com.moabam.api.domain.bug.repository.BugHistoryRepository; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.repository.CouponRepository; 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.bug.TodayBugResponse; 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; @@ -61,6 +67,9 @@ class BugControllerTest extends WithoutFilterSupporter { @Autowired ProductRepository productRepository; + @Autowired + CouponRepository couponRepository; + @DisplayName("벌레를 조회한다.") @WithMember @Test @@ -108,12 +117,12 @@ void get_today_bug_success() throws Exception { @DisplayName("벌레 상품 목록을 조회한다.") @Test - void get_products_success() throws Exception { + void get_bug_products_success() throws Exception { // given List products = productRepository.saveAll(List.of(bugProduct(), bugProduct())); ProductsResponse expected = ProductMapper.toProductsResponse(products); - // when, then + // expected String content = mockMvc.perform(get("/bugs/products") .contentType(APPLICATION_JSON)) .andExpect(status().isOk()) @@ -124,4 +133,54 @@ void get_products_success() throws Exception { ProductsResponse actual = objectMapper.readValue(content, ProductsResponse.class); assertThat(actual).isEqualTo(expected); } + + @Nested + @DisplayName("벌레 상품을 구매한다.") + class PurchaseBugProduct { + + @DisplayName("성공한다.") + @WithMember + @Test + void 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); + } + + @DisplayName("쿠폰을 적용하여 성공한다.") + @WithMember + @Test + void with_coupon_success() throws Exception { + // given + Product product = productRepository.save(bugProduct()); + Coupon coupon = couponRepository.save(discount1000Coupon()); + PurchaseProductRequest request = new PurchaseProductRequest(coupon.getId()); + + // 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 - 1000); + } + } } diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 702d0a7c..5a26e621 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -13,6 +13,11 @@ public final class CouponFixture { + public static final String DISCOUNT_1000_COUPON_NAME = "황금벌레 1000원 할인"; + public static final int DISCOUNT_1000_COUPON_STOCK = 100; + public static final LocalDateTime DISCOUNT_1000_COUPON_START_AT = LocalDateTime.of(2023, 1, 1, 0, 0); + public static final LocalDateTime DISCOUNT_1000_COUPON_END_AT = LocalDateTime.of(2023, 1, 1, 0, 0); + public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") @@ -37,6 +42,18 @@ public static Coupon coupon(String name, int startMonth, int endMonth) { .build(); } + public static Coupon discount1000Coupon() { + return Coupon.builder() + .name(DISCOUNT_1000_COUPON_NAME) + .point(1000) + .couponType(CouponType.DISCOUNT_COUPON) + .stock(DISCOUNT_1000_COUPON_STOCK) + .startAt(DISCOUNT_1000_COUPON_START_AT) + .endAt(DISCOUNT_1000_COUPON_END_AT) + .adminId(1L) + .build(); + } + public static CreateCouponRequest createCouponRequest(String couponType, int startMonth, int endMonth) { return CreateCouponRequest.builder() .name("couponName") 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..91de1efe --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -0,0 +1,24 @@ +package com.moabam.support.fixture; + +import static com.moabam.support.fixture.ProductFixture.*; + +import com.moabam.api.domain.payment.Order; +import com.moabam.api.domain.payment.Payment; + +public final class PaymentFixture { + + public static Payment bugProductPayment() { + return Payment.builder() + .memberId(1L) + .product(bugProduct()) + .order(order()) + .build(); + } + + public static Order order() { + return Order.builder() + .name(BUG_PRODUCT_NAME) + .amount(BUG_PRODUCT_PRICE) + .build(); + } +} diff --git a/src/test/java/com/moabam/support/fixture/ProductFixture.java b/src/test/java/com/moabam/support/fixture/ProductFixture.java index de5bcff6..73cb81cd 100644 --- a/src/test/java/com/moabam/support/fixture/ProductFixture.java +++ b/src/test/java/com/moabam/support/fixture/ProductFixture.java @@ -5,7 +5,7 @@ public class ProductFixture { - public static final String BUG_PRODUCT_NAME = "X10"; + public static final String BUG_PRODUCT_NAME = "황금벌레 10"; public static final int BUG_PRODUCT_PRICE = 3000; public static final int BUG_PRODUCT_QUANTITY = 10; From a18da2f799eb87fd29fc198c27b3c01918b62465 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Mon, 20 Nov 2023 14:28:04 +0900 Subject: [PATCH 063/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=A0=84=EC=B2=B4?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#109)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 전체 목록 조회 컨트롤러 추가 * refactor: 방장 member 반환 기능 삭제 * feat: 방 검색 dto 추가 * feat: 방 전체 조회 기능 구현 * fix: 서비스, 컨트롤러 수정 * test: 서비스 단위 테스트 작성 * test: 통합 테스트 작성 * fix: 피연산자 Long으로 수정 --- .../api/application/member/MemberService.java | 7 - .../application/room/RoomSearchService.java | 42 ++- .../api/application/room/RoomService.java | 7 +- .../application/room/mapper/RoomMapper.java | 27 ++ .../repository/MemberSearchRepository.java | 33 -- .../java/com/moabam/api/domain/room/Room.java | 7 + .../room/repository/RoomSearchRepository.java | 33 ++ .../repository/RoutineSearchRepository.java | 9 + .../api/dto/room/SearchAllRoomResponse.java | 24 ++ .../api/dto/room/SearchAllRoomsResponse.java | 13 + .../api/presentation/RoomController.java | 13 +- .../global/common/util/GlobalConstant.java | 4 +- .../room/RoomSearchServiceTest.java | 182 +++++++++- .../api/application/room/RoomServiceTest.java | 7 +- .../api/presentation/RoomControllerTest.java | 326 +++++++++++++++++- .../moabam/support/fixture/RoomFixture.java | 21 +- 16 files changed, 686 insertions(+), 69 deletions(-) delete mode 100644 src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java create mode 100644 src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java create mode 100644 src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 7e984867..09190e18 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -13,7 +13,6 @@ import com.moabam.api.application.auth.mapper.AuthMapper; 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.dto.auth.AuthorizationTokenInfoResponse; import com.moabam.api.dto.auth.LoginResponse; import com.moabam.global.error.exception.NotFoundException; @@ -26,7 +25,6 @@ public class MemberService { private final MemberRepository memberRepository; - private final MemberSearchRepository memberSearchRepository; public Member getById(Long memberId) { return memberRepository.findById(memberId) @@ -53,11 +51,6 @@ private String createRandomNickName() { new SecureRandom()); } - public Member getManager(Long roomId) { - return memberSearchRepository.findManager(roomId) - .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); - } - public List getRoomMembers(List memberIds) { return memberRepository.findAllById(memberIds); } diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index ec6fddc9..59dee5ba 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -1,7 +1,8 @@ package com.moabam.api.application.room; -import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.ROOM_DETAILS_ERROR; +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.Period; @@ -22,9 +23,11 @@ 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.RoomSearchRepository; import com.moabam.api.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.CertificationImageResponse; import com.moabam.api.dto.room.MyRoomResponse; @@ -33,9 +36,12 @@ 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.SearchAllRoomResponse; +import com.moabam.api.dto.room.SearchAllRoomsResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; import com.moabam.global.error.exception.NotFoundException; +import jakarta.annotation.Nullable; import lombok.RequiredArgsConstructor; @Service @@ -46,6 +52,7 @@ public class RoomSearchService { private final CertificationsSearchRepository certificationsSearchRepository; private final ParticipantSearchRepository participantSearchRepository; private final RoutineSearchRepository routineSearchRepository; + private final RoomSearchRepository roomSearchRepository; private final MemberService memberService; private final RoomCertificationService roomCertificationService; @@ -55,7 +62,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); Room room = participant.getRoom(); - String managerNickname = memberService.getManager(roomId).getNickname(); + String managerNickname = room.getManagerNickname(); List dailyMemberCertifications = certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); List routineResponses = getRoutineResponses(roomId); @@ -105,6 +112,35 @@ public RoomsHistoryResponse getJoinHistory(Long memberId) { return RoomMapper.toRoomsHistoryResponse(roomHistoryResponses); } + public SearchAllRoomsResponse searchAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { + List searchAllRoomResponses = new ArrayList<>(); + List rooms = new ArrayList<>(roomSearchRepository.findAllWithNoOffset(roomType, roomId)); + + 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 = routineSearchRepository.findAllByRoomIds(roomIds); + + for (Room room : rooms) { + List filteredRoutines = routines.stream() + .filter(routine -> routine.getRoom().getId().equals(room.getId())) + .toList(); + + boolean isPassword = !isEmpty(room.getPassword()); + + searchAllRoomResponses.add( + RoomMapper.toSearchAllRoomResponse(room, RoutineMapper.toRoutineResponses(filteredRoutines), + isPassword)); + } + + return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + } + private List getRoutineResponses(Long roomId) { List roomRoutines = routineSearchRepository.findAllByRoomId(roomId); diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index c7c217e3..4775031f 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -44,7 +44,7 @@ public class RoomService { private final MemberService memberService; @Transactional - public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { + public Long createRoom(Long memberId, String nickname, CreateRoomRequest createRoomRequest) { Room room = RoomMapper.toRoomEntity(createRoomRequest); List routines = RoutineMapper.toRoutineEntities(room, createRoomRequest.routines()); Participant participant = Participant.builder() @@ -59,6 +59,7 @@ public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { increaseRoomCount(memberId, room.getRoomType()); participant.enableManager(); + room.changeManagerNickname(nickname); Room savedRoom = roomRepository.save(room); routineRepository.saveAll(routines); participantRepository.save(participant); @@ -128,6 +129,10 @@ public void mandateRoomManager(Long managerId, Long roomId, Long memberId) { Participant memberParticipant = getParticipant(memberId, roomId); validateManagerAuthorization(managerParticipant); + Room room = managerParticipant.getRoom(); + Member member = memberService.getById(memberParticipant.getMemberId()); + room.changeManagerNickname(member.getNickname()); + managerParticipant.disableManager(); memberParticipant.enableManager(); } 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 index 6435531f..7f6abdf8 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -12,6 +12,8 @@ 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.SearchAllRoomResponse; +import com.moabam.api.dto.room.SearchAllRoomsResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; import lombok.AccessLevel; @@ -86,4 +88,29 @@ public static RoomsHistoryResponse toRoomsHistoryResponse(List routineResponses, + boolean isPassword) { + return SearchAllRoomResponse.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()) + .routine(routineResponses) + .build(); + } + + public static SearchAllRoomsResponse toSearchAllRoomsResponse(boolean hasNext, + List searchAllRoomResponses) { + return SearchAllRoomsResponse.builder() + .hasNext(hasNext) + .rooms(searchAllRoomResponses) + .build(); + } } 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 deleted file mode 100644 index 47ff7ab8..00000000 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.moabam.api.domain.member.repository; - -import static com.moabam.api.domain.member.QMember.*; -import static com.moabam.api.domain.room.QParticipant.*; - -import java.util.Optional; - -import org.springframework.stereotype.Repository; - -import com.moabam.api.domain.member.Member; -import com.querydsl.jpa.impl.JPAQueryFactory; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class MemberSearchRepository { - - private final JPAQueryFactory jpaQueryFactory; - - public Optional findManager(Long roomId) { - return Optional.ofNullable( - jpaQueryFactory - .selectFrom(member) - .innerJoin(participant).on(member.id.eq(participant.memberId)) - .where( - participant.isManager.eq(true), - participant.room.id.eq(roomId) - ) - .fetchOne() - ); - } -} diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java index 4608ced6..a1d93c20 100644 --- a/src/main/java/com/moabam/api/domain/room/Room.java +++ b/src/main/java/com/moabam/api/domain/room/Room.java @@ -76,6 +76,9 @@ public class Room extends BaseTimeEntity { @Column(name = "room_image", length = 500) private String roomImage; + @Column(name = "manager_nickname", length = 30) + private String managerNickname; + @Builder private Room(Long id, String title, String password, RoomType roomType, int certifyTime, int maxUserCount) { this.id = id; @@ -111,6 +114,10 @@ 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); 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..9313115d --- /dev/null +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java @@ -0,0 +1,33 @@ +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) + ) + .orderBy(room.id.desc()) + .limit(ROOM_FIXED_SEARCH_SIZE + 1L) + .fetch(); + } +} diff --git a/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java index 827e1b0d..087d22b6 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java @@ -25,4 +25,13 @@ public List findAllByRoomId(Long roomId) { ) .fetch(); } + + public List findAllByRoomIds(List roomIds) { + return jpaQueryFactory + .selectFrom(routine) + .where( + routine.room.id.in(roomIds) + ) + .fetch(); + } } diff --git a/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java b/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java new file mode 100644 index 00000000..1960f105 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.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 SearchAllRoomResponse( + Long id, + String title, + String image, + boolean isPassword, + String managerNickname, + int level, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + List routine +) { + +} diff --git a/src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java b/src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java new file mode 100644 index 00000000..1d2eb960 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record SearchAllRoomsResponse( + boolean hasNext, + List rooms +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 44f642b6..0d1dd917 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -10,6 +10,7 @@ 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.RequestPart; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -18,12 +19,14 @@ import com.moabam.api.application.room.RoomCertificationService; import com.moabam.api.application.room.RoomSearchService; import com.moabam.api.application.room.RoomService; +import com.moabam.api.domain.room.RoomType; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; 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.SearchAllRoomsResponse; import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -44,7 +47,7 @@ public class RoomController { public Long createRoom(@CurrentMember AuthorizationMember authorizationMember, @Valid @RequestBody CreateRoomRequest createRoomRequest) { - return roomService.createRoom(authorizationMember.id(), createRoomRequest); + return roomService.createRoom(authorizationMember.id(), authorizationMember.nickname(), createRoomRequest); } @PutMapping("/{roomId}") @@ -112,4 +115,12 @@ public MyRoomsResponse getMyRooms(@CurrentMember AuthorizationMember authorizati public RoomsHistoryResponse getJoinHistory(@CurrentMember AuthorizationMember authorizationMember) { return roomSearchService.getJoinHistory(authorizationMember.id()); } + + @GetMapping + @ResponseStatus(HttpStatus.OK) + public SearchAllRoomsResponse searchAllRooms(@RequestParam(value = "type", required = false) RoomType roomType, + @RequestParam(value = "roomId", required = false) Long roomId) { + + return roomSearchService.searchAllRooms(roomType, roomId); + } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 333f0cd5..81a059ac 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -14,6 +14,8 @@ public class GlobalConstant { public static final String SPACE = " "; public static final int ONE_HOUR = 1; public static final int HOURS_IN_A_DAY = 24; - + public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + public static final String FIREBASE_PATH = "config/moabam-firebase.json"; + public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; } diff --git a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java index ff9663a9..23d136da 100644 --- a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java @@ -1,9 +1,7 @@ package com.moabam.api.application.room; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.spy; -import static org.mockito.Mockito.when; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -20,11 +18,14 @@ 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.RoomSearchRepository; import com.moabam.api.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.MyRoomsResponse; import com.moabam.api.dto.room.RoomsHistoryResponse; +import com.moabam.api.dto.room.SearchAllRoomsResponse; import com.moabam.support.fixture.RoomFixture; @ExtendWith(MockitoExtension.class) @@ -42,6 +43,9 @@ class RoomSearchServiceTest { @Mock private RoutineSearchRepository routineSearchRepository; + @Mock + private RoomSearchRepository roomSearchRepository; + @Mock private MemberService memberService; @@ -130,4 +134,174 @@ void get_my_join_history_success() { 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(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + + // when + SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.searchAllRooms(null, null); + + // then + assertThat(searchAllRoomsResponse.hasNext()).isTrue(); + assertThat(searchAllRoomsResponse.rooms()).hasSize(10); + assertThat(searchAllRoomsResponse.rooms().get(0).id()).isEqualTo(1L); + assertThat(searchAllRoomsResponse.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(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + + // when + SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.searchAllRooms(null, 10L); + + // then + assertThat(searchAllRoomsResponse.hasNext()).isFalse(); + assertThat(searchAllRoomsResponse.rooms()).hasSize(4); + assertThat(searchAllRoomsResponse.rooms().get(0).id()).isEqualTo(11L); + assertThat(searchAllRoomsResponse.rooms().get(3).id()).isEqualTo(14L); + } } diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java index d6f8089b..e55710bc 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -70,7 +70,7 @@ void create_room_no_password_success() { given(memberService.getById(1L)).willReturn(member); // when - Long result = roomService.createRoom(1L, createRoomRequest); + Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); // then verify(roomRepository).save(any(Room.class)); @@ -98,7 +98,7 @@ void create_room_with_password_success() { given(memberService.getById(1L)).willReturn(member); // when - Long result = roomService.createRoom(1L, createRoomRequest); + Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); // then verify(roomRepository).save(any(Room.class)); @@ -115,6 +115,8 @@ void room_manager_mandate_success() { Long managerId = 1L; Long memberId = 2L; + Member member = MemberFixture.member(1234L, "닉네임"); + Room room = spy(RoomFixture.room()); given(room.getId()).willReturn(1L); @@ -126,6 +128,7 @@ void room_manager_mandate_success() { Optional.of(memberParticipant)); given(participantSearchRepository.findOne(managerId, room.getId())).willReturn( Optional.of(managerParticipant)); + given(memberService.getById(2L)).willReturn(member); // when roomService.mandateRoomManager(managerId, room.getId(), memberId); diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index f351162d..f5bebe47 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1,16 +1,11 @@ package com.moabam.api.presentation; -import static com.moabam.api.domain.room.RoomType.MORNING; -import static com.moabam.api.domain.room.RoomType.NIGHT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +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; @@ -37,6 +32,7 @@ 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; @@ -920,4 +916,312 @@ void get_join_history_success() throws Exception { .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?type=MORNING")) + .andExpect(status().isOk()) + .andDo(print()); + } + + @DisplayName("저녁 방 전체 조회 성공 - 첫 번째 조회, 다음 페이지 없음") + @WithMember(id = 1L) + @Test + void search_last_page_all_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?type=NIGHT")) + .andExpect(status().isOk()) + .andDo(print()); + } } diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java index ccdce73f..dbe7213d 100644 --- a/src/test/java/com/moabam/support/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -46,12 +46,14 @@ public static Room room(String title, RoomType roomType, int certifyTime) { .build(); } - public static List rooms() { - return List.of( - room("아침 - 첫 번째 방", RoomType.MORNING, 10), - room("아침 - 두 번째 방", RoomType.MORNING, 9), - room("밤 - 첫 번째 방", RoomType.NIGHT, 22) - ); + 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) { @@ -61,6 +63,13 @@ public static Participant participant(Room room, Long 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<>(); From bd73795ba7cadbfa77760a5dffb954e0355d428f Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 20 Nov 2023 14:56:06 +0900 Subject: [PATCH 064/185] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style: 메서드 네이밍 수정 * feat: 결제 요청 전 대기 상태 추가 * feat: 결제 요청 API 구현 * fix: Valid 어노테이션 추가 * test: 결제 요청 통합 테스트 * test: 결제 요청 서비스 테스트 * test: 결제/주문 유닛 테스트 --- .../application/payment/PaymentService.java | 33 +++++++ .../com/moabam/api/domain/payment/Order.java | 4 + .../moabam/api/domain/payment/Payment.java | 15 ++- .../api/domain/payment/PaymentStatus.java | 2 +- .../api/dto/payment/PaymentRequest.java | 9 ++ .../api/presentation/BugController.java | 3 +- .../api/presentation/PaymentController.java | 33 +++++++ .../global/error/model/ErrorMessage.java | 3 + .../payment/PaymentServiceTest.java | 53 +++++++++++ .../moabam/api/domain/payment/OrderTest.java | 13 +++ .../api/domain/payment/PaymentTest.java | 33 ++++++- .../presentation/PaymentControllerTest.java | 91 +++++++++++++++++++ .../support/fixture/PaymentFixture.java | 7 +- 13 files changed, 292 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/payment/PaymentService.java create mode 100644 src/main/java/com/moabam/api/dto/payment/PaymentRequest.java create mode 100644 src/main/java/com/moabam/api/presentation/PaymentController.java create mode 100644 src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java create mode 100644 src/test/java/com/moabam/api/presentation/PaymentControllerTest.java 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..914a3ca3 --- /dev/null +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -0,0 +1,33 @@ +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.domain.payment.Payment; +import com.moabam.api.domain.payment.repository.PaymentRepository; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.global.error.exception.NotFoundException; + +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + + @Transactional + public void requestPayment(Long memberId, Long paymentId, PaymentRequest request) { + Payment payment = getPayment(paymentId); + payment.validateByMember(memberId); + payment.request(request.orderId()); + } + + private Payment getPayment(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); + } +} diff --git a/src/main/java/com/moabam/api/domain/payment/Order.java b/src/main/java/com/moabam/api/domain/payment/Order.java index 98f78037..c2073481 100644 --- a/src/main/java/com/moabam/api/domain/payment/Order.java +++ b/src/main/java/com/moabam/api/domain/payment/Order.java @@ -47,4 +47,8 @@ private int validateAmount(int amount) { public void discountAmount(int price) { this.amount = max(MIN_AMOUNT, amount - price); } + + 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 index 8f5cfff0..7a8407d2 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -1,10 +1,12 @@ package com.moabam.api.domain.payment; +import static com.moabam.global.error.model.ErrorMessage.*; import static java.util.Objects.*; import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.product.Product; import com.moabam.global.common.entity.BaseTimeEntity; +import com.moabam.global.error.exception.BadRequestException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -63,11 +65,22 @@ public Payment(Long memberId, Product product, Coupon coupon, Order order, Strin this.coupon = coupon; this.order = requireNonNull(order); this.paymentKey = paymentKey; - this.status = requireNonNullElse(status, PaymentStatus.REQUEST); + this.status = requireNonNullElse(status, PaymentStatus.PENDING); } public void applyCoupon(Coupon coupon) { this.order.discountAmount(coupon.getPoint()); this.coupon = coupon; } + + public void validateByMember(Long memberId) { + if (!this.memberId.equals(memberId)) { + throw new BadRequestException(INVALID_MEMBER_PAYMENT); + } + } + + public void request(String orderId) { + this.order.updateId(orderId); + this.status = PaymentStatus.REQUEST; + } } diff --git a/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java index 37806cb3..1c567b22 100644 --- a/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java +++ b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java @@ -2,7 +2,7 @@ public enum PaymentStatus { - // 임시 + PENDING, REQUEST, DONE, FAIL, 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/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 287ce4b8..b8f86633 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -18,6 +18,7 @@ import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @@ -49,7 +50,7 @@ public ProductsResponse getBugProducts() { @ResponseStatus(HttpStatus.OK) public PurchaseProductResponse purchaseBugProduct(@CurrentMember AuthorizationMember member, @PathVariable Long productId, - @RequestBody PurchaseProductRequest request) { + @Valid @RequestBody PurchaseProductRequest request) { return bugService.purchaseBugProduct(member.id(), productId, request); } } 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..6ca29298 --- /dev/null +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -0,0 +1,33 @@ +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.PaymentRequest; +import com.moabam.global.auth.annotation.CurrentMember; +import com.moabam.global.auth.model.AuthorizationMember; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + + @PostMapping("/{paymentId}/request") + @ResponseStatus(HttpStatus.OK) + public void requestPayment(@CurrentMember AuthorizationMember member, + @PathVariable Long paymentId, + @Valid @RequestBody PaymentRequest request) { + paymentService.requestPayment(member.id(), paymentId, request); + } +} diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index ad826461..ee537b55 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -51,6 +51,9 @@ public enum ErrorMessage { PRODUCT_NOT_FOUND("존재하지 않는 상품입니다."), + PAYMENT_NOT_FOUND("존재하지 않는 결제 정보입니다."), + INVALID_MEMBER_PAYMENT("해당 회원의 결제 정보가 아닙니다."), + FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), CONFLICT_KNOCK("이미 콕 알림을 보낸 대상입니다."), 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..a2d0472e --- /dev/null +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -0,0 +1,53 @@ +package com.moabam.api.application.payment; + +import static com.moabam.support.fixture.PaymentFixture.*; +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.domain.payment.repository.PaymentRepository; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.payment.PaymentRequest; +import com.moabam.global.error.exception.NotFoundException; + +@ExtendWith(MockitoExtension.class) +class PaymentServiceTest { + + @InjectMocks + PaymentService paymentService; + + @Mock + ProductRepository productRepository; + + @Mock + PaymentRepository paymentRepository; + + @DisplayName("결제를 요청한다.") + @Nested + class RequestPayment { + + @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void payment_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.requestPayment(memberId, paymentId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); + } + } +} diff --git a/src/test/java/com/moabam/api/domain/payment/OrderTest.java b/src/test/java/com/moabam/api/domain/payment/OrderTest.java index bf075c4f..08cac418 100644 --- a/src/test/java/com/moabam/api/domain/payment/OrderTest.java +++ b/src/test/java/com/moabam/api/domain/payment/OrderTest.java @@ -54,4 +54,17 @@ void discount_amount_greater_than_order_amount() { assertThat(order.getAmount()).isZero(); } } + + @DisplayName("주문 id를 갱신한다.") + @Test + void update_id_success() { + // given + Order order = order(); + + // 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 index 78a6eadd..da458d68 100644 --- a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -2,20 +2,22 @@ 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.Test; import com.moabam.api.domain.coupon.Coupon; +import com.moabam.global.error.exception.BadRequestException; class PaymentTest { @DisplayName("쿠폰을 적용한다.") @Test - void apply_coupon() { + void apply_coupon_success() { // given - Payment payment = bugProductPayment(); + Payment payment = payment(bugProduct()); Coupon coupon = discount1000Coupon(); // when @@ -25,4 +27,31 @@ void apply_coupon() { assertThat(payment.getOrder().getAmount()).isEqualTo(2000); assertThat(payment.getCoupon()).isEqualTo(coupon); } + + @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); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REQUEST); + } } 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..b5e1a36b --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -0,0 +1,91 @@ +package com.moabam.api.presentation; + +import static com.moabam.support.fixture.PaymentFixture.*; +import static com.moabam.support.fixture.ProductFixture.*; +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 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.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.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.product.Product; +import com.moabam.api.domain.product.repository.ProductRepository; +import com.moabam.api.dto.payment.PaymentRequest; +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; + + @Autowired + PaymentRepository paymentRepository; + + @Autowired + ProductRepository productRepository; + + @Nested + @DisplayName("결제를 요청한다.") + class RequestPayment { + + @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}/request", 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); + assertThat(actual.getStatus()).isEqualTo(PaymentStatus.REQUEST); + } + + @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}/request", paymentId) + .contentType(APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("올바른 요청 정보가 아닙니다.")) + .andDo(print()); + } + } +} diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java index 91de1efe..40db89c0 100644 --- a/src/test/java/com/moabam/support/fixture/PaymentFixture.java +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -4,13 +4,16 @@ import com.moabam.api.domain.payment.Order; import com.moabam.api.domain.payment.Payment; +import com.moabam.api.domain.product.Product; public final class PaymentFixture { - public static Payment bugProductPayment() { + public static final String ORDER_ID = "random_order_id_123"; + + public static Payment payment(Product product) { return Payment.builder() .memberId(1L) - .product(bugProduct()) + .product(product) .order(order()) .build(); } From 5b7b46a69c61124f5f68649d98baa29cb0592bcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:13:00 +0900 Subject: [PATCH 065/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=9A=94=EC=B2=AD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#114)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 쿠폰 및 토큰 패키지 및 클래스명 변경 * refactor: 알림 패키지 및 클래스명 변경, Fcm 로직 분리 * feat: 쿠폰 발급 요청 기능 구현 * test: 쿠폰 발급 요청 기능 테스트 * test: Syntax 에러로 쿠폰 발급 관련 테스트 임시 Disabled 처리 * fix: Redis Yaml 추가 설정 * test: 중복 저장에 대한 테스트 코드 추가 * refactor: SystemClockHolder -> ClockHolder 변경 --- src/docs/asciidoc/coupon.adoc | 17 +++- .../api/application/coupon/CouponMapper.java | 4 +- .../coupon/CouponQueueService.java | 36 ++++++++ .../api/application/coupon/CouponService.java | 30 +++++-- .../notification/NotificationService.java | 5 +- .../com/moabam/api/domain/coupon/Coupon.java | 8 +- .../repository/CouponQueueRepository.java | 24 ++++++ .../coupon/repository/CouponRepository.java | 4 + .../repository/CouponSearchRepository.java | 9 -- .../redis/StringRedisRepository.java | 4 +- .../redis/ZSetRedisRepository.java | 35 ++++++++ .../api/presentation/CouponController.java | 13 ++- .../presentation/NotificationController.java | 4 +- .../global/common/util/GlobalConstant.java | 3 +- .../global/config/EmbeddedRedisConfig.java | 15 +--- .../com/moabam/global/config/RedisConfig.java | 3 +- .../global/error/model/ErrorMessage.java | 2 + src/main/resources/static/docs/coupon.html | 38 ++++++-- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../coupon/CouponQueueServiceTest.java | 82 ++++++++++++++++++ .../application/coupon/CouponServiceTest.java | 54 +++++++++++- .../notification/NotificationServiceTest.java | 7 ++ .../moabam/api/domain/coupon/CouponTest.java | 4 +- .../repository/CouponQueueRepositoryTest.java | 63 ++++++++++++++ .../CouponSearchRepositoryTest.java | 29 ------- .../redis/StringRedisRepositoryTest.java | 11 ++- .../redis/ZSetRedisRepositoryTest.java | 86 +++++++++++++++++++ .../presentation/CouponControllerTest.java | 75 ++++++++++++++++ .../moabam/support/fixture/CouponFixture.java | 8 +- src/test/resources/application.yml | 1 + 31 files changed, 578 insertions(+), 100 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/coupon/CouponQueueService.java create mode 100644 src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java create mode 100644 src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java create mode 100644 src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java create mode 100644 src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc index 9a939706..6e98b9e4 100644 --- a/src/docs/asciidoc/coupon.adoc +++ b/src/docs/asciidoc/coupon.adoc @@ -66,15 +66,24 @@ include::{snippets}/coupons/search/http-response.adoc[] --- -=== 특정 사용자의 쿠폰 보관함을 조회 +=== 특정 쿠폰에 대해 발급 - 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. + 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다. + +==== 요청 + +include::{snippets}/coupons/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/coupons/http-response.adoc[] --- -=== 쿠폰 발급 (진행 중) +=== 특정 사용자의 쿠폰 보관함을 조회 - 사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다. + 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. --- diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index 41a361ef..a04f298e 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -15,7 +15,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest request) { return Coupon.builder() .name(request.name()) .description(request.description()) - .couponType(CouponType.from(request.couponType())) + .type(CouponType.from(request.couponType())) .point(request.point()) .stock(request.stock()) .startAt(request.startAt()) @@ -33,7 +33,7 @@ public static CouponResponse toDto(Coupon coupon) { .description(coupon.getDescription()) .point(coupon.getPoint()) .stock(coupon.getStock()) - .couponType(coupon.getCouponType()) + .couponType(coupon.getType()) .startAt(coupon.getStartAt()) .endAt(coupon.getEndAt()) .build(); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java b/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java new file mode 100644 index 00000000..5da33e6a --- /dev/null +++ b/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java @@ -0,0 +1,36 @@ +package com.moabam.api.application.coupon; + +import org.springframework.stereotype.Service; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.repository.CouponQueueRepository; +import com.moabam.global.auth.model.AuthorizationMember; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CouponQueueService { + + private final CouponService couponService; + private final CouponQueueRepository couponQueueRepository; + + public void register(AuthorizationMember member, String couponName) { + double registerTime = System.currentTimeMillis(); + + if (canRegister(couponName)) { + log.info("{} 쿠폰이 모두 발급되었습니다.", couponName); + return; + } + + couponQueueRepository.addQueue(couponName, member.nickname(), registerTime); + } + + private boolean canRegister(String couponName) { + Coupon coupon = couponService.validateCouponPeriod(couponName); + + return coupon.getStock() <= couponQueueRepository.queueSize(coupon.getName()); + } +} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 8e0c710d..f967c096 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -14,6 +14,7 @@ import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.auth.model.AuthorizationMember; +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; @@ -28,6 +29,7 @@ public class CouponService { private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; + private final ClockHolder clockHolder; @Transactional public void createCoupon(AuthorizationMember admin, CreateCouponRequest request) { @@ -48,14 +50,14 @@ public void deleteCoupon(AuthorizationMember admin, Long couponId) { } public CouponResponse getCouponById(Long couponId) { - Coupon coupon = couponSearchRepository.findById(couponId) + Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); return CouponMapper.toDto(coupon); } public List getCoupons(CouponSearchRequest request) { - LocalDateTime now = LocalDateTime.now(); + LocalDateTime now = clockHolder.times(); List coupons = couponSearchRepository.findAllByStatus(now, request); return coupons.stream() @@ -63,6 +65,24 @@ public List getCoupons(CouponSearchRequest request) { .toList(); } + public Coupon validateCouponPeriod(String couponName) { + LocalDateTime now = clockHolder.times(); + Coupon coupon = couponRepository.findByName(couponName) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); + + if (!now.isBefore(coupon.getStartAt()) && !now.isAfter(coupon.getEndAt())) { + return coupon; + } + + throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD_END); + } + + private void validateCouponPeriod(LocalDateTime startAt, LocalDateTime endAt) { + if (startAt.isAfter(endAt)) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); + } + } + private void validateAdminRole(AuthorizationMember admin) { if (!admin.role().equals(Role.ADMIN)) { throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND); @@ -74,10 +94,4 @@ private void validateConflictCouponName(String name) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); } } - - private void validateCouponPeriod(LocalDateTime startAt, LocalDateTime endAt) { - if (startAt.isAfter(endAt)) { - throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); - } - } } diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 12722c9d..f9172904 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -2,7 +2,6 @@ import static com.moabam.global.common.util.GlobalConstant.*; -import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.function.Predicate; @@ -19,6 +18,7 @@ import com.moabam.api.dto.notification.KnockNotificationStatusResponse; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; +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; @@ -38,6 +38,7 @@ public class NotificationService { private final RoomService roomService; private final NotificationRepository notificationRepository; private final ParticipantSearchRepository participantSearchRepository; + private final ClockHolder clockHolder; @Transactional public void sendKnockNotification(AuthorizationMember member, Long targetId, Long roomId) { @@ -55,7 +56,7 @@ public void sendKnockNotification(AuthorizationMember member, Long targetId, Lon @Scheduled(cron = "0 50 * * * *") public void sendCertificationTimeNotification() { - int certificationTime = (LocalDateTime.now().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; + int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime); participants.parallelStream().forEach(participant -> { diff --git a/src/main/java/com/moabam/api/domain/coupon/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java index 1440069b..c3e5c2be 100644 --- a/src/main/java/com/moabam/api/domain/coupon/Coupon.java +++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java @@ -48,8 +48,8 @@ public class Coupon extends BaseTimeEntity { private String description; @Enumerated(value = EnumType.STRING) - @Column(name = "coupon_type", nullable = false) - private CouponType couponType; + @Column(name = "type", nullable = false) + private CouponType type; @ColumnDefault("1") @Column(name = "stock", nullable = false) @@ -66,12 +66,12 @@ public class Coupon extends BaseTimeEntity { private Long adminId; @Builder - private Coupon(String name, int point, String description, CouponType couponType, int stock, LocalDateTime startAt, + private Coupon(String name, int point, String description, CouponType type, int stock, LocalDateTime startAt, LocalDateTime endAt, Long adminId) { this.name = requireNonNull(name); this.point = validatePoint(point); this.description = Optional.ofNullable(description).orElse(BLANK); - this.couponType = requireNonNull(couponType); + this.type = requireNonNull(type); this.stock = validateStock(stock); this.startAt = requireNonNull(startAt); this.endAt = requireNonNull(endAt); diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java new file mode 100644 index 00000000..6c82e022 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java @@ -0,0 +1,24 @@ +package com.moabam.api.domain.coupon.repository; + +import static java.util.Objects.*; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponQueueRepository { + + private final ZSetRedisRepository zSetRedisRepository; + + public void addQueue(String couponName, String memberNickname, double score) { + zSetRedisRepository.addIfAbsent(requireNonNull(couponName), requireNonNull(memberNickname), score); + } + + public Long queueSize(String couponName) { + return zSetRedisRepository.size(requireNonNull(couponName)); + } +} 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 index b82795bc..02760025 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java @@ -1,10 +1,14 @@ package com.moabam.api.domain.coupon.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import com.moabam.api.domain.coupon.Coupon; public interface CouponRepository extends JpaRepository { + Optional findByName(String couponName); + boolean existsByName(String name); } 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 index 6687f343..7bbc3889 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java @@ -4,7 +4,6 @@ import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import org.springframework.stereotype.Repository; @@ -22,14 +21,6 @@ public class CouponSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public Optional findById(Long couponId) { - return Optional.ofNullable( - jpaQueryFactory.selectFrom(coupon) - .where(coupon.id.eq(couponId)) - .fetchOne() - ); - } - public List findAllByStatus(LocalDateTime now, CouponSearchRequest request) { return jpaQueryFactory.selectFrom(coupon) .where(filterCouponStatus(now, request)) diff --git a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java index 0b55a397..ce1f067b 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java @@ -11,7 +11,7 @@ @RequiredArgsConstructor public class StringRedisRepository { - private final RedisTemplate redisTemplate; + private final RedisTemplate redisTemplate; public void save(String key, String value, Duration timeout) { redisTemplate @@ -24,7 +24,7 @@ public void delete(String key) { } public String get(String key) { - return redisTemplate + return (String)redisTemplate .opsForValue() .get(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..e02a3c4e --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -0,0 +1,35 @@ +package com.moabam.api.infrastructure.redis; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class ZSetRedisRepository { + + private final RedisTemplate redisTemplate; + + public void addIfAbsent(String key, String value, double score) { + if (redisTemplate.opsForZSet().score(key, value) == null) { + redisTemplate + .opsForZSet() + .add(key, value, score); + } + } + + public Long size(String key) { + return redisTemplate + .opsForZSet() + .size(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/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index a3bf5a8c..a1aa4664 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -8,9 +8,11 @@ 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.CouponQueueService; import com.moabam.api.application.coupon.CouponService; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponSearchRequest; @@ -26,6 +28,7 @@ public class CouponController { private final CouponService couponService; + private final CouponQueueService couponQueueService; @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) @@ -36,13 +39,13 @@ public void createCoupon(@CurrentMember AuthorizationMember admin, @DeleteMapping("/admins/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public void deleteCoupon(@CurrentMember AuthorizationMember admin, @PathVariable Long couponId) { + public void deleteCoupon(@CurrentMember AuthorizationMember admin, @PathVariable("couponId") Long couponId) { couponService.deleteCoupon(admin, couponId); } @GetMapping("/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public CouponResponse getCouponById(@PathVariable Long couponId) { + public CouponResponse getCouponById(@PathVariable("couponId") Long couponId) { return couponService.getCouponById(couponId); } @@ -51,4 +54,10 @@ public CouponResponse getCouponById(@PathVariable Long couponId) { public List getCoupons(@Valid @RequestBody CouponSearchRequest request) { return couponService.getCoupons(request); } + + @PostMapping("/coupons") + public void registerCouponQueue(@CurrentMember AuthorizationMember member, + @RequestParam("couponName") String couponName) { + couponQueueService.register(member, couponName); + } } diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java index 4c792c17..7ddb34f1 100644 --- a/src/main/java/com/moabam/api/presentation/NotificationController.java +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -19,8 +19,8 @@ public class NotificationController { private final NotificationService notificationService; @GetMapping("/rooms/{roomId}/members/{memberId}") - public void sendKnockNotification(@CurrentMember AuthorizationMember member, @PathVariable Long roomId, - @PathVariable Long memberId) { + public void sendKnockNotification(@CurrentMember AuthorizationMember member, @PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId) { notificationService.sendKnockNotification(member, memberId, roomId); } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 81a059ac..5b3d4dae 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -14,8 +14,7 @@ public class GlobalConstant { public static final String SPACE = " "; public static final int ONE_HOUR = 1; public static final int HOURS_IN_A_DAY = 24; - public static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; - public static final String FIREBASE_PATH = "config/moabam-firebase.json"; + public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; } diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 44f50cf2..02268602 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -13,7 +13,6 @@ 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.core.StringRedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.util.StringUtils; @@ -50,20 +49,12 @@ public RedisConnectionFactory redisConnectionFactory(EmbeddedRedisConfig embedde return new LettuceConnectionFactory(redisHost, embeddedRedisConfig.getAvailablePort()); } - @Bean - public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory redisConnectionFactory) { - StringRedisTemplate stringRedisTemplate = new StringRedisTemplate(); - stringRedisTemplate.setKeySerializer(new StringRedisSerializer()); - stringRedisTemplate.setValueSerializer(new StringRedisSerializer()); - stringRedisTemplate.setConnectionFactory(redisConnectionFactory); - - return stringRedisTemplate; - } - @Bean public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); - redisTemplate.setDefaultSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + 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/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index fef4a99e..9d017cce 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -29,7 +29,8 @@ public RedisConnectionFactory redisConnectionFactory() { public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { RedisTemplate redisTemplate = new RedisTemplate<>(); redisTemplate.setKeySerializer(new StringRedisSerializer()); - redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(String.class)); + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); redisTemplate.setConnectionFactory(redisConnectionFactory); return redisTemplate; diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index ee537b55..f0a7039a 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -60,7 +60,9 @@ public enum ErrorMessage { INVALID_COUPON_POINT("쿠폰의 보너스 포인트는 0 이상이어야 합니다."), INVALID_COUPON_STOCK("쿠폰의 재고는 0 이상이어야 합니다."), + INVALID_COUPON_STOCK_END("쿠폰 발급 선착순이 마감되었습니다."), INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."), + INVALID_COUPON_PERIOD_END("쿠폰 발급 가능 기간이 아닙니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index dc7b0eb3..e4adf5d6 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -596,19 +596,47 @@

응답

-

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

+

특정 쿠폰에 대해 발급

-
사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다.
+
사용자가 발급 가능한 쿠폰을 선착순으로 발급 받습니다.
+
+
+
+

요청

+
+
+
POST /coupons HTTP/1.1
+Content-Type: application/x-www-form-urlencoded
+Host: localhost:8080
+Content-Length: 32
+
+couponName=Not+found+coupon+name
+
+
+

응답

+
+
+
HTTP/1.1 404 Not Found
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+Content-Type: application/json
+Content-Length: 56
+
+{
+  "message" : "존재하지 않는 쿠폰입니다."
+}

+
diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 0e9f868c..206bb94a 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -487,7 +487,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java new file mode 100644 index 00000000..31d90635 --- /dev/null +++ b/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java @@ -0,0 +1,82 @@ +package com.moabam.api.application.coupon; + +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.repository.CouponQueueRepository; +import com.moabam.global.auth.model.AuthorizationMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +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.CouponFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class CouponQueueServiceTest { + + @InjectMocks + private CouponQueueService couponQueueService; + + @Mock + private CouponQueueRepository couponQueueRepository; + + @Mock + private CouponService couponService; + + @WithMember + @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") + @Test + void couponQueueService_register() { + // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + + given(couponService.validateCouponPeriod(any(String.class))).willReturn(coupon); + given(couponQueueRepository.queueSize(any(String.class))).willReturn(coupon.getStock() - 1L); + + // When + couponQueueService.register(member, coupon.getName()); + + // Then + verify(couponQueueRepository).addQueue(any(String.class), any(String.class), any(double.class)); + } + + @WithMember + @DisplayName("해당 쿠폰은 발급 가능 기간이 아니다. - BadRequestException") + @Test + void couponQueueService_register_BadRequestException() { + // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + given(couponService.validateCouponPeriod(any(String.class))) + .willThrow(new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD_END)); + + // When & Then + assertThatThrownBy(() -> couponQueueService.register(member, "couponName")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage()); + } + + @WithMember + @DisplayName("해당 쿠폰은 마감된 쿠폰이다. - Void") + @Test + void couponQueueService_register_End() { + // Given + AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); + Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + + given(couponService.validateCouponPeriod(any(String.class))).willReturn(coupon); + given(couponQueueRepository.queueSize(any(String.class))).willReturn((long)coupon.getStock()); + + // When & Then + assertThatNoException().isThrownBy(() -> couponQueueService.register(member, coupon.getName())); + } +} diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 00e2f444..06b6e480 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -26,6 +26,7 @@ import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; +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; @@ -46,6 +47,9 @@ class CouponServiceTest { @Mock private CouponSearchRepository couponSearchRepository; + @Mock + private ClockHolder clockHolder; + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test @@ -175,7 +179,7 @@ void couponService_deleteCoupon_NotFoundException() { void couponService_getCouponById() { // Given Coupon coupon = CouponFixture.coupon(10, 100); - given(couponSearchRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); // When CouponResponse actual = couponService.getCouponById(1L); @@ -189,7 +193,7 @@ void couponService_getCouponById() { @Test void couponService_getCouponById_NotFoundException() { // Given - given(couponSearchRepository.findById(any(Long.class))).willReturn(Optional.empty()); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); // When & Then assertThatThrownBy(() -> couponService.getCouponById(1L)) @@ -205,6 +209,7 @@ void couponService_getCoupons(List coupons) { CouponSearchRequest request = CouponFixture.couponSearchRequest(true, true, true); given(couponSearchRepository.findAllByStatus(any(LocalDateTime.class), any(CouponSearchRequest.class))) .willReturn(coupons); + given(clockHolder.times()).willReturn(LocalDateTime.now()); // When List actual = couponService.getCoupons(request); @@ -212,4 +217,49 @@ void couponService_getCoupons(List coupons) { // Then assertThat(actual).hasSize(coupons.size()); } + + @DisplayName("해당 쿠폰은 발급 가능 기간입니다. - Coupon") + @Test + void couponService_validateCouponPeriod() { + // Given + LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 0); + Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + given(couponRepository.findByName(any(String.class))).willReturn(Optional.of(coupon)); + given(clockHolder.times()).willReturn(now); + + // When + Coupon actual = couponService.validateCouponPeriod(coupon.getName()); + + // Then + assertThat(actual.getName()).isEqualTo(coupon.getName()); + } + + @DisplayName("해당 쿠폰은 발급 가능 기간이 아닙니다. - BadRequestException") + @Test + void couponService_validateCouponPeriod_BadRequestException() { + // Given + LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); + Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + given(couponRepository.findByName(any(String.class))).willReturn(Optional.of(coupon)); + given(clockHolder.times()).willReturn(now); + + // When & Then + assertThatThrownBy(() -> couponService.validateCouponPeriod("couponName")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage()); + } + + @DisplayName("해당 쿠폰은 존재하지 않습니다. - NotFoundException") + @Test + void couponService_validateCouponPeriod_NotFoundException() { + // Given + LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); + given(couponRepository.findByName(any(String.class))).willReturn(Optional.empty()); + given(clockHolder.times()).willReturn(now); + + // When & Then + assertThatThrownBy(() -> couponService.validateCouponPeriod("Not found coupon name")) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); + } } diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 1dc9b4bc..778f8fbd 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -22,6 +23,7 @@ import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; 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; @@ -46,6 +48,9 @@ class NotificationServiceTest { @Mock private ParticipantSearchRepository participantSearchRepository; + @Mock + private ClockHolder clockHolder; + @WithMember @DisplayName("성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test @@ -119,6 +124,7 @@ void notificationService_sendCertificationTimeNotification(List par // Given given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + given(clockHolder.times()).willReturn(LocalDateTime.now()); // When notificationService.sendCertificationTimeNotification(); @@ -135,6 +141,7 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L // Given given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn(null); + given(clockHolder.times()).willReturn(LocalDateTime.now()); // When notificationService.sendCertificationTimeNotification(); diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java index 9b11c988..29d27f45 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -24,7 +24,7 @@ void coupon() { Coupon actual = Coupon.builder() .name("couponName") .point(10) - .couponType(CouponType.MORNING_COUPON) + .type(CouponType.MORNING_COUPON) .stock(100) .startAt(startAt) .endAt(endAt) @@ -36,7 +36,7 @@ void coupon() { assertThat(actual.getDescription()).isBlank(); assertThat(actual.getPoint()).isEqualTo(10); assertThat(actual.getStock()).isEqualTo(100); - assertThat(actual.getCouponType()).isEqualTo(CouponType.MORNING_COUPON); + assertThat(actual.getType()).isEqualTo(CouponType.MORNING_COUPON); assertThat(actual.getStartAt()).isEqualTo(startAt); assertThat(actual.getEndAt()).isEqualTo(endAt); assertThat(actual.getAdminId()).isEqualTo(1L); diff --git a/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java new file mode 100644 index 00000000..d4a4219f --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java @@ -0,0 +1,63 @@ +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 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.ZSetRedisRepository; + +@ExtendWith(MockitoExtension.class) +class CouponQueueRepositoryTest { + + @InjectMocks + private CouponQueueRepository couponQueueRepository; + + @Mock + private ZSetRedisRepository zSetRedisRepository; + + @DisplayName("특정 쿠폰의 대기열에 사용자가 성공적으로 등록된다. - Void") + @Test + void addQueue() { + // When + couponQueueRepository.addQueue("couponName", "memberNickname", 1); + + // Then + verify(zSetRedisRepository).addIfAbsent(any(String.class), any(String.class), any(Double.class)); + } + + @DisplayName("특정 쿠폰의 대기열에 사용자 등록 시, 필요한 값이 NULL 이다.- NullPointerException") + @Test + void addQueue_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponQueueRepository.addQueue(null, "value", 1)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("특정 쿠폰을 발급한 사용자가 3명이다. - Long") + @Test + void queueSize() { + // Given + given(zSetRedisRepository.size(any(String.class))).willReturn(3L); + + // When + long actual = couponQueueRepository.queueSize("key"); + + // Then + assertThat(actual).isEqualTo(3); + } + + @DisplayName("특정 쿠폰을 발급한 사용자 수 조회 시, 필요한 값이 Null이다. - NullPointerException") + @Test + void queueSize_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponQueueRepository.queueSize(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 index 3bc94ec1..91df34ad 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -4,10 +4,8 @@ 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.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; @@ -17,8 +15,6 @@ import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.global.config.JpaConfig; -import com.moabam.global.error.exception.NotFoundException; -import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.fixture.CouponFixture; @DataJpaTest @@ -31,31 +27,6 @@ class CouponSearchRepositoryTest { @Autowired private CouponSearchRepository couponSearchRepository; - @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") - @Test - void couponSearchRepository_findById() { - // Given - Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); - - // When - Coupon actual = couponSearchRepository.findById(coupon.getId()) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); - - // Then - assertThat(actual.getStock()).isEqualTo(coupon.getStock()); - assertThat(actual.getPoint()).isEqualTo(coupon.getPoint()); - } - - @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") - @Test - void couponSearchRepository_findById_NotFoundException() { - // When - Optional actual = couponSearchRepository.findById(77777L); - - // Then - assertThat(actual).isEmpty(); - } - @DisplayName("모든 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest diff --git a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java index ebd08268..e38fbe34 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java @@ -12,9 +12,8 @@ import org.springframework.boot.test.context.SpringBootTest; import com.moabam.global.config.EmbeddedRedisConfig; -import com.moabam.global.config.RedisConfig; -@SpringBootTest(classes = {RedisConfig.class, EmbeddedRedisConfig.class, StringRedisRepository.class}) +@SpringBootTest(classes = {EmbeddedRedisConfig.class, StringRedisRepository.class}) class StringRedisRepositoryTest { @Autowired @@ -36,14 +35,14 @@ void setDown() { @DisplayName("레디스에 문자열 데이터가 성공적으로 저장된다. - Void") @Test - void string_redis_repository_save() { + void stringRedisRepository_save() { // Then assertThat(stringRedisRepository.get(key)).isEqualTo(value); } @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") @Test - void string_redis_repository_delete() { + void stringRedisRepository_delete() { // When stringRedisRepository.delete(key); @@ -53,7 +52,7 @@ void string_redis_repository_delete() { @DisplayName("레디스의 특정 데이터가 성공적으로 조회된다. - String(Value)") @Test - void string_redis_repository_get() { + void stringRedisRepository_get() { // When String actual = stringRedisRepository.get(key); @@ -63,7 +62,7 @@ void string_redis_repository_get() { @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") @Test - void string_redis_repository_hasKey() { + void stringRedisRepository_hasKey() { // When & Then assertThat(stringRedisRepository.hasKey("not found key")).isFalse(); } 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..0713fdff --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java @@ -0,0 +1,86 @@ +package com.moabam.api.infrastructure.redis; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; + +import com.moabam.global.config.EmbeddedRedisConfig; + +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class}) +class ZSetRedisRepositoryTest { + + @Autowired + private ZSetRedisRepository zSetRedisRepository; + + @Autowired + private RedisTemplate redisTemplate; + + String key = "key"; + String value = "value"; + + @AfterEach + void afterEach() { + if (zSetRedisRepository.hasKey(key)) { + zSetRedisRepository.delete(key); + } + } + + @DisplayName("레디스의 SortedSet 데이터가 성공적으로 저장된다. - Void") + @Test + void setRedisRepository_addIfAbsent() { + // When + zSetRedisRepository.addIfAbsent(key, value, 1); + + // Then + assertThat(zSetRedisRepository.size(key)).isEqualTo(1); + } + + @DisplayName("이미 존재하는 값을 한 번 더 저장을 시도한다. - Void") + @Test + void setRedisRepository_addIfAbsent_not_update() { + // When + zSetRedisRepository.addIfAbsent(key, value, 1); + zSetRedisRepository.addIfAbsent(key, value, 5); + + // Then + assertThat(redisTemplate.opsForZSet().score(key, value)).isEqualTo(1); + } + + @DisplayName("레디스의 특정 키의 사이즈가 성공적으로 반환된다. - int") + @Test + void setRedisRepository_size() { + // Given + zSetRedisRepository.addIfAbsent(key, value, 1); + + // When + long actual = zSetRedisRepository.size(key); + + // Then + assertThat(actual).isEqualTo(1); + } + + @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") + @Test + void setRedisRepository_delete() { + // Given + zSetRedisRepository.addIfAbsent(key, value, 1); + + // When + zSetRedisRepository.delete(key); + + // Then + assertThat(zSetRedisRepository.hasKey(key)).isFalse(); + } + + @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") + @Test + void setRedisRepository_hasKey() { + // When & Then + assertThat(zSetRedisRepository.hasKey("not found key")).isFalse(); + } +} diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 834c2f1b..6b332f61 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -1,12 +1,14 @@ 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.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -17,6 +19,7 @@ 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; @@ -29,6 +32,7 @@ import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponSearchRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; +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; @@ -51,6 +55,9 @@ class CouponControllerTest extends WithoutFilterSupporter { @Autowired private CouponRepository couponRepository; + @MockBean + private ClockHolder clockHolder; + @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test @@ -227,4 +234,72 @@ void couponController_getCoupons_not_status(List coupons) throws Excepti .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(0))); } + + @WithMember(nickname = "member-coupon-1") + @DisplayName("쿠폰 발급 요청을 한다. - Void") + @Test + void couponController_registerCouponQueue() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); + LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.times()).willReturn(now); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()))) + .andExpect(status().isOk()); + } + + @WithMember(nickname = "member-coupon-2") + @DisplayName("발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") + @Test + void couponController_registerCouponQueue_BadRequestException() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); + LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 1); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.times()).willReturn(now); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage())); + } + + @WithMember + @DisplayName("존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") + @Test + void couponController_registerCouponQueue_NotFoundException() throws Exception { + // Given + Coupon coupon = CouponFixture.coupon("Not found coupon name", 1, 2); + LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); + + given(clockHolder.times()).willReturn(now); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", coupon.getName())) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isNotFound()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + } } diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 5a26e621..1f2082f1 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -22,10 +22,10 @@ public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") .point(point) - .couponType(CouponType.MORNING_COUPON) + .type(CouponType.MORNING_COUPON) .stock(stock) .startAt(LocalDateTime.of(2023, 1, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, 1, 1, 0, 0)) + .endAt(LocalDateTime.of(2023, 2, 1, 0, 0)) .adminId(1L) .build(); } @@ -34,7 +34,7 @@ public static Coupon coupon(String name, int startMonth, int endMonth) { return Coupon.builder() .name(name) .point(10) - .couponType(CouponType.MORNING_COUPON) + .type(CouponType.MORNING_COUPON) .stock(100) .startAt(LocalDateTime.of(2023, startMonth, 1, 0, 0)) .endAt(LocalDateTime.of(2023, endMonth, 1, 0, 0)) @@ -46,7 +46,7 @@ public static Coupon discount1000Coupon() { return Coupon.builder() .name(DISCOUNT_1000_COUPON_NAME) .point(1000) - .couponType(CouponType.DISCOUNT_COUPON) + .type(CouponType.DISCOUNT_COUPON) .stock(DISCOUNT_1000_COUPON_STOCK) .startAt(DISCOUNT_1000_COUPON_START_AT) .endAt(DISCOUNT_1000_COUPON_END_AT) diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 6d2f0e0d..44173d12 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -14,6 +14,7 @@ spring: hibernate: format_sql: true highlight_sql: true + # Redis data: redis: From 8e62640a436eecb3f5fc591aa5f95f976989e310 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Mon, 20 Nov 2023 17:18:51 +0900 Subject: [PATCH 066/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B3=80=EA=B2=BD=20(#117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 방 전체 목록 조회 컨트롤러 추가 * refactor: 방장 member 반환 기능 삭제 * feat: 방 검색 dto 추가 * feat: 방 전체 조회 기능 구현 * fix: 서비스, 컨트롤러 수정 * test: 서비스 단위 테스트 작성 * test: 통합 테스트 작성 * fix: 피연산자 Long으로 수정 * feat: 방 상세 목록 조회 날짜별 조회로 기능 추가 - 방이 인증된 날짜들은 조회하는 유저의 날짜에서 일주일 전까지 가져옴 * refactor: 사용자의 찌르기 확인 기능 수정 * feat: 사용자별 콕찌르기 여부 확인 추가 * feat: Response에 요청자의 memberId 추가 --- .../notification/NotificationMapper.java | 13 ----- .../notification/NotificationService.java | 19 +++---- .../application/room/RoomSearchService.java | 57 +++++++++++-------- .../room/mapper/CertificationsMapper.java | 3 +- .../application/room/mapper/RoomMapper.java | 3 +- .../CertificationsSearchRepository.java | 4 +- .../ParticipantSearchRepository.java | 11 ---- .../KnockNotificationStatusResponse.java | 13 ----- .../api/dto/room/RoomDetailsResponse.java | 1 + .../room/TodayCertificateRankResponse.java | 3 +- .../api/presentation/RoomController.java | 7 ++- .../notification/NotificationServiceTest.java | 21 +++---- .../ParticipantSearchRepositoryTest.java | 15 ----- .../api/presentation/RoomControllerTest.java | 6 +- 14 files changed, 67 insertions(+), 109 deletions(-) delete mode 100644 src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java diff --git a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java index 792821e1..57e027c1 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationMapper.java @@ -1,10 +1,7 @@ package com.moabam.api.application.notification; -import java.util.List; - import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; -import com.moabam.api.dto.notification.KnockNotificationStatusResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -27,14 +24,4 @@ public static Message toMessageEntity(Notification notification, String fcmToken .setToken(fcmToken) .build(); } - - public static KnockNotificationStatusResponse toKnockNotificationStatusResponse( - List knockedMembersId, - List notKnockedMembersId - ) { - return KnockNotificationStatusResponse.builder() - .knockedMembersId(knockedMembersId) - .notKnockedMembersId(notKnockedMembersId) - .build(); - } } diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index f9172904..0f7f30d8 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -15,7 +15,6 @@ 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.dto.notification.KnockNotificationStatusResponse; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.common.util.ClockHolder; @@ -67,22 +66,20 @@ public void sendCertificationTimeNotification() { }); } - /** - * TODO : 영명-재윤님 방 조회하실 때, 특정 사용자의 방 내 참여자들에 대한 콕 찌르기 여부를 반환해주는 메서드이니 사용하시기 바랍니다. - */ - public KnockNotificationStatusResponse checkMyKnockNotificationStatusInRoom(AuthorizationMember member, - Long roomId) { - List participants = participantSearchRepository.findOtherParticipantsInRoom(member.id(), roomId); + public List getMyKnockedNotificationStatusInRoom(Long memberId, Long roomId, + List participants) { + List filteredParticipants = participants.stream() + .filter(participant -> !participant.getMemberId().equals(memberId)) + .toList(); Predicate knockPredicate = targetId -> - notificationRepository.existsByKey(generateKnockKey(member.id(), targetId, roomId)); + notificationRepository.existsByKey(generateKnockKey(memberId, targetId, roomId)); - Map> knockNotificationStatus = participants.stream() + Map> knockNotificationStatus = filteredParticipants.stream() .map(Participant::getMemberId) .collect(Collectors.partitioningBy(knockPredicate)); - return NotificationMapper - .toKnockNotificationStatusResponse(knockNotificationStatus.get(true), knockNotificationStatus.get(false)); + return knockNotificationStatus.get(true); } private void validateConflictKnockNotification(String knockKey) { diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index 59dee5ba..1d7e3c57 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -14,6 +14,7 @@ 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.RoomMapper; import com.moabam.api.application.room.mapper.RoutineMapper; @@ -55,24 +56,24 @@ public class RoomSearchService { private final RoomSearchRepository roomSearchRepository; private final MemberService memberService; private final RoomCertificationService roomCertificationService; + private final NotificationService notificationService; - public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId) { - LocalDate today = LocalDate.now(); + 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, today); + certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, date); List routineResponses = getRoutineResponses(roomId); - List todayCertificateRankResponses = getTodayCertificateRankResponses(roomId, - dailyMemberCertifications, today); - List certifiedDates = getCertifiedDates(roomId, today); + List todayCertificateRankResponses = getTodayCertificateRankResponses(memberId, + roomId, dailyMemberCertifications, date); + List certifiedDates = getCertifiedDatesBeforeWeek(roomId); double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), room.getCurrentUserCount()); - return RoomMapper.toRoomDetailsResponse(room, managerNickname, routineResponses, certifiedDates, + return RoomMapper.toRoomDetailsResponse(memberId, room, managerNickname, routineResponses, certifiedDates, todayCertificateRankResponses, completePercentage); } @@ -147,25 +148,31 @@ private List getRoutineResponses(Long roomId) { return RoutineMapper.toRoutineResponses(roomRoutines); } - private List getTodayCertificateRankResponses(Long roomId, - List dailyMemberCertifications, LocalDate today) { + private List getTodayCertificateRankResponses(Long memberId, Long roomId, + List dailyMemberCertifications, LocalDate date) { List responses = new ArrayList<>(); - List certifications = certificationsSearchRepository.findCertifications(roomId, today); + List certifications = certificationsSearchRepository.findCertifications(roomId, date); List participants = participantSearchRepository.findParticipantsByRoomId(roomId); List members = memberService.getRoomMembers(participants.stream() .map(Participant::getMemberId) .toList()); - addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, today); - addUncompletedMembers(responses, dailyMemberCertifications, members, participants, today); + List myKnockedNotificationStatusInRoom = notificationService.getMyKnockedNotificationStatusInRoom( + memberId, roomId, participants); + + addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, date, + myKnockedNotificationStatusInRoom); + addUncompletedMembers(responses, dailyMemberCertifications, members, participants, date, + myKnockedNotificationStatusInRoom); return responses; } private void addCompletedMembers(List responses, List dailyMemberCertifications, List members, - List certifications, List participants, LocalDate today) { + List certifications, List participants, LocalDate date, + List myKnockedNotificationStatusInRoom) { int rank = 1; @@ -175,12 +182,15 @@ private void addCompletedMembers(List responses, .findAny() .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); - int contributionPoint = calculateContributionPoint(member.getId(), participants, today); + int contributionPoint = calculateContributionPoint(member.getId(), participants, date); List certificationImageResponses = CertificationsMapper.toCertificateImageResponses(member.getId(), certifications); + boolean isNotificationSent = myKnockedNotificationStatusInRoom.contains(member.getId()); + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( - rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses); + rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses, + isNotificationSent); rank += 1; responses.add(response); @@ -189,7 +199,7 @@ private void addCompletedMembers(List responses, private void addUncompletedMembers(List responses, List dailyMemberCertifications, List members, - List participants, LocalDate today) { + List participants, LocalDate date, List myKnockedNotificationStatusInRoom) { List allMemberIds = participants.stream() .map(Participant::getMemberId) @@ -207,29 +217,30 @@ private void addUncompletedMembers(List responses, .findAny() .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); - int contributionPoint = calculateContributionPoint(memberId, participants, today); + int contributionPoint = calculateContributionPoint(memberId, participants, date); + boolean isNotificationSent = myKnockedNotificationStatusInRoom.contains(member.getId()); - TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( - 500, member, contributionPoint, "https://~awake", "https://~sleep", null); + TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse(500, member, + contributionPoint, "https://~awake", "https://~sleep", null, isNotificationSent); responses.add(response); } } - private int calculateContributionPoint(Long memberId, List participants, LocalDate today) { + private int calculateContributionPoint(Long memberId, List participants, LocalDate date) { Participant participant = participants.stream() .filter(p -> p.getMemberId().equals(memberId)) .findAny() .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); - int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), today).getDays() + 1; + int participatedDays = Period.between(participant.getCreatedAt().toLocalDate(), date).getDays() + 1; return (int)(((double)participant.getCertifyCount() / participatedDays) * 100); } - private List getCertifiedDates(Long roomId, LocalDate today) { + private List getCertifiedDatesBeforeWeek(Long roomId) { List certifications = certificationsSearchRepository.findDailyRoomCertifications( - roomId, today); + roomId, LocalDate.now()); return certifications.stream().map(DailyRoomCertification::getCertifiedAt).toList(); } 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 index 9e612284..788c03d2 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java @@ -41,12 +41,13 @@ public static List toCertificateImageResponses(Long public static TodayCertificateRankResponse toTodayCertificateRankResponse(int rank, Member member, int contributionPoint, String awakeImage, String sleepImage, - List certificationImageResponses) { + List certificationImageResponses, boolean isNotificationSent) { return TodayCertificateRankResponse.builder() .rank(rank) .memberId(member.getId()) .nickname(member.getNickname()) + .isNotificationSent(isNotificationSent) .profileImage(member.getProfileImage()) .contributionPoint(contributionPoint) .awakeImage(awakeImage) 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 index 7f6abdf8..9fe19658 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -32,11 +32,12 @@ public static Room toRoomEntity(CreateRoomRequest createRoomRequest) { .build(); } - public static RoomDetailsResponse toRoomDetailsResponse(Room room, String managerNickname, + public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room, String managerNickname, List routineResponses, List certifiedDates, List todayCertificateRankResponses, double completePercentage) { return RoomDetailsResponse.builder() .roomId(room.getId()) + .myMemberId(memberId) .title(room.getTitle()) .managerNickName(managerNickname) .roomImage(room.getRoomImage()) 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 index 587037b9..67cd0b86 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java @@ -70,12 +70,12 @@ public Optional findDailyRoomCertification(Long roomId, .fetchOne()); } - public List findDailyRoomCertifications(Long roomId, LocalDate date) { + public List findDailyRoomCertifications(Long roomId, LocalDate today) { return jpaQueryFactory .selectFrom(dailyRoomCertification) .where( dailyRoomCertification.roomId.eq(roomId), - dailyRoomCertification.certifiedAt.eq(date) + dailyRoomCertification.certifiedAt.between(today.minusWeeks(1), today) ) .fetch(); } 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 index d22fdae6..c99a6d48 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -65,17 +65,6 @@ public List findAllParticipantsByMemberId(Long memberId) { .fetch(); } - public List findOtherParticipantsInRoom(Long memberId, Long roomId) { - return jpaQueryFactory - .selectFrom(participant) - .where( - participant.room.id.eq(roomId), - participant.memberId.ne(memberId), - participant.deletedAt.isNull() - ) - .fetch(); - } - public List findAllByRoomCertifyTime(int certifyTime) { return jpaQueryFactory .selectFrom(participant) diff --git a/src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java b/src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java deleted file mode 100644 index 6c0044dc..00000000 --- a/src/main/java/com/moabam/api/dto/notification/KnockNotificationStatusResponse.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.moabam.api.dto.notification; - -import java.util.List; - -import lombok.Builder; - -@Builder -public record KnockNotificationStatusResponse( - List knockedMembersId, - List notKnockedMembersId -) { - -} diff --git a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java index 0d870761..bdac9689 100644 --- a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -8,6 +8,7 @@ @Builder public record RoomDetailsResponse( Long roomId, + Long myMemberId, String title, String managerNickName, String roomImage, diff --git a/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java index 832f4fa8..414be53a 100644 --- a/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java +++ b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java @@ -2,8 +2,6 @@ import java.util.List; -import com.moabam.api.dto.room.CertificationImageResponse; - import lombok.Builder; @Builder @@ -11,6 +9,7 @@ public record TodayCertificateRankResponse( int rank, Long memberId, String nickname, + boolean isNotificationSent, String profileImage, int contributionPoint, String awakeImage, diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 0d1dd917..d37a54eb 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,5 +1,6 @@ package com.moabam.api.presentation; +import java.time.LocalDate; import java.util.List; import org.springframework.http.HttpStatus; @@ -72,12 +73,12 @@ public void exitRoom(@CurrentMember AuthorizationMember authorizationMember, @Pa roomService.exitRoom(authorizationMember.id(), roomId); } - @GetMapping("/{roomId}") + @GetMapping("/{roomId}/{date}") @ResponseStatus(HttpStatus.OK) public RoomDetailsResponse getRoomDetails(@CurrentMember AuthorizationMember authorizationMember, - @PathVariable("roomId") Long roomId) { + @PathVariable("roomId") Long roomId, @PathVariable("date") LocalDate date) { - return roomSearchService.getRoomDetails(authorizationMember.id(), roomId); + return roomSearchService.getRoomDetails(authorizationMember.id(), roomId, date); } @PostMapping("/{roomId}/certification") diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 778f8fbd..b54b7236 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -19,7 +19,6 @@ 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.dto.notification.KnockNotificationStatusResponse; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; @@ -158,17 +157,14 @@ void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List actual = + notificationService.getMyKnockedNotificationStatusInRoom(member.id(), 1L, participants); // Then - assertThat(actual.knockedMembersId()).hasSize(3); - assertThat(actual.notKnockedMembersId()).isEmpty(); + assertThat(actual).hasSize(2); } @WithMember @@ -178,16 +174,15 @@ void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { // Given AuthorizationMember member = AuthorizationThreadLocal.getAuthorizationMember(); - given(participantSearchRepository.findOtherParticipantsInRoom(any(Long.class), any(Long.class))) - .willReturn(participants); + + // given given(notificationRepository.existsByKey(any(String.class))).willReturn(false); // When - KnockNotificationStatusResponse actual = - notificationService.checkMyKnockNotificationStatusInRoom(member, 1L); + List actual = + notificationService.getMyKnockedNotificationStatusInRoom(member.id(), 1L, participants); // Then - assertThat(actual.knockedMembersId()).isEmpty(); - assertThat(actual.notKnockedMembersId()).hasSize(3); + assertThat(actual).isEmpty(); } } 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 index 7df838fe..c3007a48 100644 --- a/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java @@ -42,19 +42,4 @@ void participantSearchRepository_findAllByRoomCertifyTime(Room room, List") - @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideRoomAndParticipants") - @ParameterizedTest - void participantSearchRepository_findOtherParticipantsInRoom(Room room, List participants) { - // Given - roomRepository.save(room); - participantRepository.saveAll(participants); - - // When - List actual = participantSearchRepository.findOtherParticipantsInRoom(7L, room.getId()); - - // Then - assertThat(actual).hasSize(4); - } } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index f5bebe47..c6ae23ac 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -825,8 +825,12 @@ void get_room_details_test() throws Exception { LocalDate.now()); dailyRoomCertificationRepository.save(dailyRoomCertification); + DailyRoomCertification dailyRoomCertification1 = RoomFixture.dailyRoomCertification(room.getId(), + LocalDate.of(LocalDate.now().getYear(), LocalDate.now().getMonth(), LocalDate.now().getDayOfMonth() - 3)); + dailyRoomCertificationRepository.save(dailyRoomCertification1); + // expected - mockMvc.perform(get("/rooms/" + room.getId())) + mockMvc.perform(get("/rooms/" + room.getId() + "/" + LocalDate.now())) .andExpect(status().isOk()) .andDo(print()); } From cf6070f335f154ec2100c3a9c8446b76e75ce296 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 20 Nov 2023 17:37:38 +0900 Subject: [PATCH 067/185] =?UTF-8?q?feat:=20=EC=B9=B4=EC=B9=B4=EC=98=A4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20API(/members/login/kakao/oauth)?= =?UTF-8?q?=20Get=20->=20Post=20=EB=B3=80=EA=B2=BD=20(#118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: profile 환경에 따른 cookie 설정 분리 및 config 업데이트 * test: profile에 따른 쿠키 생성 테스트 * feat: Get에서 Post로 변경 * refactor: CookieUtils 변경 * feat: config 변경 * fix: merge confilt 해결 * feat: Cookie secure 추가 --- .../application/auth/mapper/PathMapper.java | 11 +- .../api/presentation/MemberController.java | 15 +- .../moabam/global/auth/filter/PathFilter.java | 9 + .../global/common/util/CookieUtils.java | 37 + .../com/moabam/global/config/WebConfig.java | 4 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2807 ++++++++++++---- src/main/resources/static/docs/index.html | 2910 +++++++++++++---- .../resources/static/docs/notification.html | 2603 ++++++++++++--- .../presentation/MemberControllerTest.java | 14 +- .../global/common/util/CookieMakeTest.java | 54 +- .../moabam/global/filter/PathFilterTest.java | 2 +- 12 files changed, 6776 insertions(+), 1692 deletions(-) create mode 100644 src/main/java/com/moabam/global/common/util/CookieUtils.java 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 index 2aa36005..4ef9db71 100644 --- a/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java +++ b/src/main/java/com/moabam/api/application/auth/mapper/PathMapper.java @@ -9,7 +9,6 @@ import com.moabam.api.domain.member.Role; import com.moabam.global.auth.handler.PathResolver; -import jakarta.annotation.Nonnull; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -20,12 +19,12 @@ public static PathResolver.Path parsePath(String uri) { return parsePath(uri, null, null); } - public static PathResolver.Path parsePath(String uri, @Nonnull List params) { - if (!params.isEmpty() && params.get(0) instanceof Role) { - return parsePath(uri, (List)params, null); - } + public static PathResolver.Path pathWithRole(String uri, List params) { + return parsePath(uri, params, null); + } - return parsePath(uri, null, (List)params); + 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) { diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 87e6e0f4..a08590db 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -2,7 +2,8 @@ import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -31,21 +32,21 @@ public void socialLogin(HttpServletResponse httpServletResponse) { authorizationService.redirectToLoginPage(httpServletResponse); } - @GetMapping("/login/kakao/oauth") + @PostMapping("/login/kakao/oauth") @ResponseStatus(HttpStatus.OK) - public LoginResponse authorizationTokenIssue(@ModelAttribute AuthorizationCodeResponse authorizationCodeResponse, + public LoginResponse authorizationTokenIssue(@RequestBody AuthorizationCodeResponse authorizationCodeResponse, HttpServletResponse httpServletResponse) { AuthorizationTokenResponse tokenResponse = authorizationService.requestToken(authorizationCodeResponse); - AuthorizationTokenInfoResponse authorizationTokenInfoResponse = - authorizationService.requestTokenInfo(tokenResponse); + AuthorizationTokenInfoResponse authorizationTokenInfoResponse = authorizationService.requestTokenInfo( + tokenResponse); return authorizationService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); } @GetMapping("/logout") @ResponseStatus(HttpStatus.OK) - public void logout(@CurrentMember AuthorizationMember authorizationMember, - HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { + public void logout(@CurrentMember AuthorizationMember authorizationMember, HttpServletRequest httpServletRequest, + HttpServletResponse httpServletResponse) { authorizationService.logout(authorizationMember, httpServletRequest, httpServletResponse); } } diff --git a/src/main/java/com/moabam/global/auth/filter/PathFilter.java b/src/main/java/com/moabam/global/auth/filter/PathFilter.java index 9d3c9bc1..8c7266ee 100644 --- a/src/main/java/com/moabam/global/auth/filter/PathFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/PathFilter.java @@ -9,6 +9,7 @@ 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; @@ -34,6 +35,14 @@ public void doFilterInternal(HttpServletRequest request, HttpServletResponse res } }); + 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/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java new file mode 100644 index 00000000..892268da --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -0,0 +1,37 @@ +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) { + Cookie cookie = new Cookie(name, value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int)expireTime); + cookie.setAttribute("SameSite", "Lax"); + + return cookie; + } + + public static Cookie typeCookie(String value, long expireTime) { + Cookie cookie = new Cookie("token_type", value); + cookie.setSecure(true); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge((int)expireTime); + cookie.setAttribute("SameSite", "Lax"); + + return cookie; + } + + public static Cookie deleteCookie(Cookie cookie) { + cookie.setMaxAge(0); + cookie.setPath("/"); + return cookie; + } +} diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index 63d3bfa6..b4371519 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -56,8 +56,8 @@ public PathResolver pathResolver() { PathMapper.parsePath("/webjars/*"), PathMapper.parsePath("/favicon/*"), PathMapper.parsePath("/*/icon-*"), - PathMapper.parsePath("/serverTime", List.of(HttpMethod.GET)) - )) + PathMapper.parsePath("/favicon.ico"), + PathMapper.pathWithMethod("/serverTime", List.of(HttpMethod.GET)))) .build(); return new PathResolver(path); diff --git a/src/main/resources/config b/src/main/resources/config index 2b721299..2a1a59a1 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2b7212992e491c6d222f0b6a9b9289525be07008 +Subproject commit 2a1a59a16d8e868185c125a58aec0682f3c53f0d diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index e4adf5d6..1ac3df18 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,464 +1,2135 @@ - - - - -쿠폰(Coupon) - - + + + + + 쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 192
@@ -473,11 +2144,11 @@ 

요청

"startAt" : "2023-01-01T00:00", "endAt" : "2023-02-01T00:00" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -488,27 +2159,27 @@ 

응답

{ "message" : "쿠폰의 이름이 중복되었습니다." }
-
-
-
-
-
-

쿠폰 삭제

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

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/77777777777 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -519,28 +2190,28 @@ 

응답

{ "message" : "존재하지 않는 쿠폰입니다." }
-
-
-
-
-
-

특정 쿠폰 조회

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

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -551,22 +2222,22 @@ 

응답

{ "message" : "존재하지 않는 쿠폰입니다." }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

+
+
POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 84
@@ -577,11 +2248,11 @@ 

요청

"couponNotStarted" : false, "couponEnded" : false }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -590,33 +2261,33 @@ 

응답

Content-Length: 3 [ ]
-
-
-
-
-
-
-

특정 쿠폰에 대해 발급

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

요청

-
-
+
+
+
+
+
+
+

특정 쿠폰에 대해 발급

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

요청

+
+
POST /coupons HTTP/1.1
 Content-Type: application/x-www-form-urlencoded
 Host: localhost:8080
 Content-Length: 32
 
 couponName=Not+found+coupon+name
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -627,36 +2298,36 @@ 

응답

{ "message" : "존재하지 않는 쿠폰입니다." }
-
-
-
-
-
-
-

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

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

쿠폰 사용 (진행 중)

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

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

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 62f80fd6..ded2abe8 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -1,630 +1,2322 @@ - - - - -MOABAM API 문서 - - - - + + + + + MOABAM API 문서 + + + +
-
-

1. 개요

-
-
-

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

-
-
-

1.1. API 서버 경로

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

환경

DNS

비고

개발(dev)

dev-api.moabam.com

운영(prod)

api.moabam.com

-
- - - - - -
- - -
-

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

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

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

-
-
-
-
-
-

1.2. 응답형식

-
-

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

-
-
-

1.2.1. 정상(2XX)

- ---- - - - - - - - - - + + +
응답데이터가 없는 경우응답데이터가 있는 경우
-
+
+

1. 개요

+
+
+

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

+
+
+

1.1. API 서버 경로

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

환경

DNS

비고

개발(dev)

dev-api.moabam.com +

운영(prod)

api.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)

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

-
-
-
-
+
+
+
+
+
+
+

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 index 206bb94a..1f39f893 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -1,473 +1,2144 @@ - - - - -알림(Notification) - - + + + + + 알림(Notification) + +
-
-

알림(Notification)

-
-
-
-
콕 찌르기 알림 기능을 제공합니다.
-
-
-
-

콕 찌르기 알림

-
-
+
+

알림(Notification)

+
+
+
+
콕 찌르기 알림 기능을 제공합니다.
+
+
+
+

콕 찌르기 알림

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

요청

-
-
+
+
+

요청

+
+
GET /notifications/rooms/3/members/3 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 409 Conflict
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -478,17 +2149,17 @@ 

응답

{ "message" : "이미 콕 알림을 보낸 대상입니다." }
-
-
-
-
-
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index db6b248e..0ac1187b 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -112,6 +112,7 @@ void social_login_signUp_request_success() throws Exception { contentParams.add("client_secret", oAuthConfig.client().clientSecret()); AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + String requestBody = objectMapper.writeValueAsString(authorizationCodeResponse); AuthorizationTokenResponse authorizationTokenResponse = AuthorizationResponseFixture.authorizationTokenResponse(); String response = objectMapper.writeValueAsString(authorizationTokenResponse); @@ -132,8 +133,9 @@ void social_login_signUp_request_success() throws Exception { .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) .andRespond(withSuccess(tokenInfoResponse, MediaType.APPLICATION_JSON)); - mockMvc.perform(get("/members/login/kakao/oauth") - .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + mockMvc.perform(post("/members/login/kakao/oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) .andExpectAll( status().isOk(), MockMvcResultMatchers.content().contentType(MediaType.APPLICATION_JSON), @@ -161,6 +163,7 @@ void authorization_token_request_fail(int code) throws Exception { contentParams.add("client_secret", oAuthConfig.client().clientSecret()); AuthorizationCodeResponse authorizationCodeResponse = AuthorizationResponseFixture.successCodeResponse(); + String requestBody = objectMapper.writeValueAsString(authorizationCodeResponse); // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().tokenUri())) @@ -169,8 +172,9 @@ void authorization_token_request_fail(int code) throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - mockMvc.perform(get("/members/login/kakao/oauth") - .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) + mockMvc.perform(post("/members/login/kakao/oauth") + .contentType(MediaType.APPLICATION_JSON) + .content(requestBody)) .andExpect(status().isBadRequest()); } @@ -191,7 +195,7 @@ void token_info_response_fail(int code) throws Exception { .andExpect(MockRestRequestMatchers.header("Authorization", "Bearer accessToken")) .andRespond(withStatus(HttpStatusCode.valueOf(code))); - mockMvc.perform(get("/members/login/kakao/oauth") + mockMvc.perform(post("/members/login/kakao/oauth") .flashAttr("authorizationCodeResponse", authorizationCodeResponse)) .andExpect(status().isBadRequest()); } diff --git a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java index 00addf88..63cd7a79 100644 --- a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java +++ b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java @@ -3,55 +3,55 @@ import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; - -import com.moabam.global.common.util.cookie.CookieDevUtils; -import com.moabam.global.common.util.cookie.CookieProdUtils; -import com.moabam.global.common.util.cookie.CookieUtils; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; import jakarta.servlet.http.Cookie; +@ExtendWith(MockitoExtension.class) class CookieMakeTest { - CookieUtils cookieDevUtils; - CookieUtils cookieProdUtils; - - @BeforeEach - void setUp() { - cookieDevUtils = new CookieDevUtils(); - cookieProdUtils = new CookieProdUtils(); - } - @DisplayName("prod환경에서 cookie 생성 테스트") @Test - void prodUtilsTest() { + void create_test() { // Given - Cookie cookie = cookieProdUtils.tokenCookie("access_token", "value", 10000); + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000); // When + Then assertAll( () -> assertThat(cookie.getSecure()).isTrue(), () -> assertThat(cookie.getSecure()).isTrue(), () -> assertThat(cookie.getPath()).isEqualTo("/"), - () -> assertThat(cookie.getMaxAge()).isEqualTo(10000) + () -> assertThat(cookie.getMaxAge()).isEqualTo(10000), + () -> assertThat(cookie.getAttribute("SameSite")).isEqualTo("Lax") ); } - @DisplayName("dev환경에서 cookie 생성 테스트") + @DisplayName("") @Test - void devUtilsTest() { - // Given - Cookie cookie = cookieDevUtils.tokenCookie("access_token", "value", 10000); + void delete_test() { + // given + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000); - // When + Then + // when + Cookie deletedCookie = CookieUtils.deleteCookie(cookie); + + // 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") + () -> assertThat(deletedCookie.getMaxAge()).isZero(), + () -> assertThat(deletedCookie.getPath()).isEqualTo("/") ); } + + @DisplayName("") + @Test + void typeCookie_create_test() { + // Given + When + Cookie cookie = CookieUtils.typeCookie("Bearer", 10000); + + // then + assertThat(cookie.getName()).isEqualTo("token_type"); + } } diff --git a/src/test/java/com/moabam/global/filter/PathFilterTest.java b/src/test/java/com/moabam/global/filter/PathFilterTest.java index 33d24808..9c172127 100644 --- a/src/test/java/com/moabam/global/filter/PathFilterTest.java +++ b/src/test/java/com/moabam/global/filter/PathFilterTest.java @@ -36,7 +36,7 @@ class PathFilterTest { @DisplayName("Authentication을 넘기기 위한 필터 설정") @ParameterizedTest @ValueSource(strings = { - "GET", "POST", "PATCH", "DELETE" + "GET", "POST", "PATCH", "DELETE", "OPTIONS" }) void filter_pass_for_authentication(String method) throws ServletException, IOException { // given From fb060f9313576245f9991847dce595e6efec29bd Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 20 Nov 2023 18:21:54 +0900 Subject: [PATCH 068/185] =?UTF-8?q?=08feat:=20=EB=B0=A9=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#121)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 검색 Native Query 작성 * feat: 방 검색 서비스 기능 구현 * test: 방 검색 서비스 테스트 * feat: 방 검색 컨트롤러 구현 * test: 방 컨트롤러 통합 테스트 구현 * refactor: 파라미터 타입 통일화 * refactor: controller 타입 수정 --- .../application/room/RoomSearchService.java | 36 +++- .../application/room/mapper/RoomMapper.java | 6 +- .../room/repository/RoomRepository.java | 37 ++++ .../api/dto/room/RoomDetailsResponse.java | 6 +- .../api/dto/room/SearchAllRoomResponse.java | 2 +- .../api/presentation/RoomController.java | 11 +- .../room/RoomSearchServiceTest.java | 108 ++++++++++- .../api/presentation/RoomControllerTest.java | 170 +++++++++++++++++- 8 files changed, 361 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index 1d7e3c57..912eea8f 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -28,6 +28,7 @@ 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.RoutineSearchRepository; import com.moabam.api.dto.room.CertificationImageResponse; @@ -54,6 +55,7 @@ public class RoomSearchService { private final ParticipantSearchRepository participantSearchRepository; private final RoutineSearchRepository routineSearchRepository; private final RoomSearchRepository roomSearchRepository; + private final RoomRepository roomRepository; private final MemberService memberService; private final RoomCertificationService roomCertificationService; private final NotificationService notificationService; @@ -116,7 +118,38 @@ public RoomsHistoryResponse getJoinHistory(Long memberId) { public SearchAllRoomsResponse searchAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { List searchAllRoomResponses = new ArrayList<>(); List rooms = new ArrayList<>(roomSearchRepository.findAllWithNoOffset(roomType, roomId)); + boolean hasNext = isHasNext(searchAllRoomResponses, rooms); + return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + } + + public SearchAllRoomsResponse search(String keyword, @Nullable RoomType roomType, @Nullable Long roomId) { + List searchAllRoomResponses = 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(searchAllRoomResponses, rooms); + + return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + } + + private boolean isHasNext(List searchAllRoomResponses, List rooms) { boolean hasNext = false; if (rooms.size() > ROOM_FIXED_SEARCH_SIZE) { @@ -138,8 +171,7 @@ public SearchAllRoomsResponse searchAllRooms(@Nullable RoomType roomType, @Nulla RoomMapper.toSearchAllRoomResponse(room, RoutineMapper.toRoutineResponses(filteredRoutines), isPassword)); } - - return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + return hasNext; } private List getRoutineResponses(Long roomId) { 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 index 9fe19658..0c52f23c 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -42,14 +42,14 @@ public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room .managerNickName(managerNickname) .roomImage(room.getRoomImage()) .level(room.getLevel()) - .roomType(room.getRoomType().name()) + .roomType(room.getRoomType()) .certifyTime(room.getCertifyTime()) .currentUserCount(room.getCurrentUserCount()) .maxUserCount(room.getMaxUserCount()) .announcement(room.getAnnouncement()) .completePercentage(completePercentage) .certifiedDates(certifiedDates) - .routine(routineResponses) + .routines(routineResponses) .todayCertificateRank(todayCertificateRankResponses) .build(); } @@ -103,7 +103,7 @@ public static SearchAllRoomResponse toSearchAllRoomResponse(Room room, List { + @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% " + + "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 " + + "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 " + + "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 " + + "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/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java index bdac9689..abb7d06b 100644 --- a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -3,6 +3,8 @@ import java.time.LocalDate; import java.util.List; +import com.moabam.api.domain.room.RoomType; + import lombok.Builder; @Builder @@ -13,14 +15,14 @@ public record RoomDetailsResponse( String managerNickName, String roomImage, int level, - String roomType, + RoomType roomType, int certifyTime, int currentUserCount, int maxUserCount, String announcement, double completePercentage, List certifiedDates, - List routine, + List routines, List todayCertificateRank ) { diff --git a/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java b/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java index 1960f105..cdb35ab4 100644 --- a/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java +++ b/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java @@ -18,7 +18,7 @@ public record SearchAllRoomResponse( int certifyTime, int currentUserCount, int maxUserCount, - List routine + List routines ) { } diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index d37a54eb..3a354d64 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -119,9 +119,18 @@ public RoomsHistoryResponse getJoinHistory(@CurrentMember AuthorizationMember au @GetMapping @ResponseStatus(HttpStatus.OK) - public SearchAllRoomsResponse searchAllRooms(@RequestParam(value = "type", required = false) RoomType roomType, + public SearchAllRoomsResponse searchAllRooms(@RequestParam(value = "roomType", required = false) RoomType roomType, @RequestParam(value = "roomId", required = false) Long roomId) { return roomSearchService.searchAllRooms(roomType, roomId); } + + @GetMapping("/search") + @ResponseStatus(HttpStatus.OK) + public SearchAllRoomsResponse search(@RequestParam(value = "keyword") String keyword, + @RequestParam(value = "roomType", required = false) RoomType roomType, + @RequestParam(value = "roomId", required = false) Long roomId) { + + return roomSearchService.search(keyword, roomType, roomId); + } } diff --git a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java index 23d136da..9507fdb0 100644 --- a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java @@ -1,7 +1,10 @@ package com.moabam.api.application.room; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; +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; @@ -21,6 +24,7 @@ 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.RoutineSearchRepository; import com.moabam.api.dto.room.MyRoomsResponse; @@ -52,6 +56,9 @@ class RoomSearchServiceTest { @Mock private RoomCertificationService certificationService; + @Mock + private RoomRepository roomRepository; + @DisplayName("유저가 참여중인 방 목록 조회 성공") @Test void get_my_rooms_success() { @@ -304,4 +311,101 @@ void search_last_page_all_morning_night_rooms_success() { assertThat(searchAllRoomsResponse.rooms().get(0).id()).isEqualTo(11L); assertThat(searchAllRoomsResponse.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(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + + // when + SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.search("번째", null, null); + + // then + assertThat(searchAllRoomsResponse.hasNext()).isTrue(); + assertThat(searchAllRoomsResponse.rooms()).hasSize(10); + } } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index c6ae23ac..146d5155 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1147,15 +1147,15 @@ void search_last_page_all_morning_rooms_success() throws Exception { routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); // expected - mockMvc.perform(get("/rooms?type=MORNING")) + mockMvc.perform(get("/rooms?roomType=MORNING")) .andExpect(status().isOk()) .andDo(print()); } - @DisplayName("저녁 방 전체 조회 성공 - 첫 번째 조회, 다음 페이지 없음") + @DisplayName("방 검색 조회 성공 - 키워드만 존재") @WithMember(id = 1L) @Test - void search_last_page_all_night_rooms_success() throws Exception { + 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); @@ -1224,7 +1224,169 @@ void search_last_page_all_night_rooms_success() throws Exception { routine20, routine21, routine22, routine23, routine24, routine25, routine26, routine27, routine28)); // expected - mockMvc.perform(get("/rooms?type=NIGHT")) + 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()); } From 923e5d8c382fb3dc317cc9cf81feb8b467cf19bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Tue, 21 Nov 2023 14:03:19 +0900 Subject: [PATCH 069/185] =?UTF-8?q?style:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=8F?= =?UTF-8?q?=20=EB=85=B8=EC=85=98=20=EB=A9=94=EC=84=9C=EB=93=9C,=20?= =?UTF-8?q?=EB=B3=80=EC=88=98,=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/application/coupon/CouponMapper.java | 22 +++++----- .../coupon/CouponQueueService.java | 6 +-- .../api/application/coupon/CouponService.java | 22 +++++----- .../notification/NotificationService.java | 25 ++++++----- .../application/room/RoomSearchService.java | 2 +- .../moabam/api/domain/coupon/CouponType.java | 8 ++-- .../repository/CouponQueueRepository.java | 4 +- .../repository/CouponSearchRepository.java | 22 +++++----- .../repository/NotificationRepository.java | 6 +-- .../moabam/api/dto/coupon/CouponResponse.java | 6 +-- .../api/dto/coupon/CouponSearchRequest.java | 12 ------ .../api/dto/coupon/CouponStatusRequest.java | 12 ++++++ .../api/dto/coupon/CreateCouponRequest.java | 2 +- .../fcm/FcmMapper.java} | 6 +-- .../api/infrastructure/fcm/FcmService.java | 7 ++-- .../api/presentation/CouponController.java | 18 ++++---- .../presentation/NotificationController.java | 2 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 19 ++++----- .../coupon/CouponQueueServiceTest.java | 12 +++--- .../application/coupon/CouponServiceTest.java | 42 +++++++++---------- .../notification/NotificationServiceTest.java | 34 +++++++-------- .../api/domain/coupon/CouponTypeTest.java | 2 +- .../repository/CouponQueueRepositoryTest.java | 8 ++-- .../CouponSearchRepositoryTest.java | 18 ++++---- .../NotificationRepositoryTest.java | 8 ++-- .../infrastructure/fcm/FcmServiceTest.java | 4 +- .../presentation/CouponControllerTest.java | 20 ++++----- .../NotificationControllerTest.java | 2 +- .../moabam/support/fixture/CouponFixture.java | 14 +++---- .../support/fixture/CouponSnippetFixture.java | 24 +++++------ 31 files changed, 194 insertions(+), 197 deletions(-) delete mode 100644 src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java create mode 100644 src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java rename src/main/java/com/moabam/api/{application/notification/NotificationMapper.java => infrastructure/fcm/FcmMapper.java} (75%) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index a04f298e..f61a6c3c 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -11,15 +11,15 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class CouponMapper { - public static Coupon toEntity(Long adminId, CreateCouponRequest request) { + public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { return Coupon.builder() - .name(request.name()) - .description(request.description()) - .type(CouponType.from(request.couponType())) - .point(request.point()) - .stock(request.stock()) - .startAt(request.startAt()) - .endAt(request.endAt()) + .name(coupon.name()) + .description(coupon.description()) + .type(CouponType.from(coupon.type())) + .point(coupon.point()) + .stock(coupon.stock()) + .startAt(coupon.startAt()) + .endAt(coupon.endAt()) .adminId(adminId) .build(); } @@ -27,13 +27,13 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest request) { // TODO : Admin Table 생성 시, 관리자 명 추가할 예정 public static CouponResponse toDto(Coupon coupon) { return CouponResponse.builder() - .couponId(coupon.getId()) - .couponAdminName(coupon.getAdminId() + "admin") + .id(coupon.getId()) + .adminName(coupon.getAdminId() + "admin") .name(coupon.getName()) .description(coupon.getDescription()) .point(coupon.getPoint()) .stock(coupon.getStock()) - .couponType(coupon.getType()) + .type(coupon.getType()) .startAt(coupon.getStartAt()) .endAt(coupon.getEndAt()) .build(); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java b/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java index 5da33e6a..4bc3a165 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java @@ -25,12 +25,12 @@ public void register(AuthorizationMember member, String couponName) { return; } - couponQueueRepository.addQueue(couponName, member.nickname(), registerTime); + couponQueueRepository.addIfAbsent(couponName, member.nickname(), registerTime); } private boolean canRegister(String couponName) { - Coupon coupon = couponService.validateCouponPeriod(couponName); + Coupon coupon = couponService.validatePeriod(couponName); - return coupon.getStock() <= couponQueueRepository.queueSize(coupon.getName()); + return coupon.getStock() <= couponQueueRepository.size(coupon.getName()); } } diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index f967c096..5bf73a70 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -11,7 +11,7 @@ import com.moabam.api.domain.coupon.repository.CouponSearchRepository; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; -import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CouponStatusRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.auth.model.AuthorizationMember; import com.moabam.global.common.util.ClockHolder; @@ -32,31 +32,31 @@ public class CouponService { private final ClockHolder clockHolder; @Transactional - public void createCoupon(AuthorizationMember admin, CreateCouponRequest request) { + public void create(AuthorizationMember admin, CreateCouponRequest request) { validateAdminRole(admin); - validateConflictCouponName(request.name()); - validateCouponPeriod(request.startAt(), request.endAt()); + validateConflictName(request.name()); + validatePeriod(request.startAt(), request.endAt()); Coupon coupon = CouponMapper.toEntity(admin.id(), request); couponRepository.save(coupon); } @Transactional - public void deleteCoupon(AuthorizationMember admin, Long couponId) { + public void delete(AuthorizationMember admin, Long couponId) { validateAdminRole(admin); Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); couponRepository.delete(coupon); } - public CouponResponse getCouponById(Long couponId) { + public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); return CouponMapper.toDto(coupon); } - public List getCoupons(CouponSearchRequest request) { + public List getAllByStatus(CouponStatusRequest request) { LocalDateTime now = clockHolder.times(); List coupons = couponSearchRepository.findAllByStatus(now, request); @@ -65,7 +65,7 @@ public List getCoupons(CouponSearchRequest request) { .toList(); } - public Coupon validateCouponPeriod(String couponName) { + public Coupon validatePeriod(String couponName) { LocalDateTime now = clockHolder.times(); Coupon coupon = couponRepository.findByName(couponName) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); @@ -77,7 +77,7 @@ public Coupon validateCouponPeriod(String couponName) { throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD_END); } - private void validateCouponPeriod(LocalDateTime startAt, LocalDateTime endAt) { + private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { if (startAt.isAfter(endAt)) { throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); } @@ -89,8 +89,8 @@ private void validateAdminRole(AuthorizationMember admin) { } } - private void validateConflictCouponName(String name) { - if (couponRepository.existsByName(name)) { + private void validateConflictName(String couponName) { + if (couponRepository.existsByName(couponName)) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_NAME); } } diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 0f7f30d8..a8f891ab 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -40,21 +40,21 @@ public class NotificationService { private final ClockHolder clockHolder; @Transactional - public void sendKnockNotification(AuthorizationMember member, Long targetId, Long roomId) { + public void sendKnock(AuthorizationMember member, Long targetId, Long roomId) { roomService.validateRoomById(roomId); String knockKey = generateKnockKey(member.id(), targetId, roomId); - validateConflictKnockNotification(knockKey); + validateConflictKnock(knockKey); validateFcmToken(targetId); String fcmToken = notificationRepository.findFcmTokenByMemberId(targetId); String notificationBody = String.format(KNOCK_BODY, member.nickname()); - fcmService.sendAsyncFcm(fcmToken, notificationBody); - notificationRepository.saveKnockNotification(knockKey); + fcmService.sendAsync(fcmToken, notificationBody); + notificationRepository.saveKnock(knockKey); } @Scheduled(cron = "0 50 * * * *") - public void sendCertificationTimeNotification() { + public void sendCertificationTime() { int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime); @@ -62,28 +62,27 @@ public void sendCertificationTimeNotification() { String roomTitle = participant.getRoom().getTitle(); String fcmToken = notificationRepository.findFcmTokenByMemberId(participant.getMemberId()); String notificationBody = String.format(CERTIFY_TIME_BODY, roomTitle); - fcmService.sendAsyncFcm(fcmToken, notificationBody); + fcmService.sendAsync(fcmToken, notificationBody); }); } - public List getMyKnockedNotificationStatusInRoom(Long memberId, Long roomId, - List participants) { + public List getMyKnockStatusInRoom(Long memberId, Long roomId, List participants) { List filteredParticipants = participants.stream() .filter(participant -> !participant.getMemberId().equals(memberId)) .toList(); Predicate knockPredicate = targetId -> - notificationRepository.existsByKey(generateKnockKey(memberId, targetId, roomId)); + notificationRepository.existsKnockByKnockKey(generateKnockKey(memberId, targetId, roomId)); - Map> knockNotificationStatus = filteredParticipants.stream() + Map> knockStatus = filteredParticipants.stream() .map(Participant::getMemberId) .collect(Collectors.partitioningBy(knockPredicate)); - return knockNotificationStatus.get(true); + return knockStatus.get(true); } - private void validateConflictKnockNotification(String knockKey) { - if (notificationRepository.existsByKey(knockKey)) { + private void validateConflictKnock(String knockKey) { + if (notificationRepository.existsKnockByKnockKey(knockKey)) { throw new ConflictException(ErrorMessage.CONFLICT_KNOCK); } } diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/RoomSearchService.java index 912eea8f..778ea11f 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/RoomSearchService.java @@ -190,7 +190,7 @@ private List getTodayCertificateRankResponses(Long .map(Participant::getMemberId) .toList()); - List myKnockedNotificationStatusInRoom = notificationService.getMyKnockedNotificationStatusInRoom( + List myKnockedNotificationStatusInRoom = notificationService.getMyKnockStatusInRoom( memberId, roomId, participants); addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, date, diff --git a/src/main/java/com/moabam/api/domain/coupon/CouponType.java b/src/main/java/com/moabam/api/domain/coupon/CouponType.java index f8bfa263..722d4e8a 100644 --- a/src/main/java/com/moabam/api/domain/coupon/CouponType.java +++ b/src/main/java/com/moabam/api/domain/coupon/CouponType.java @@ -23,16 +23,16 @@ public enum CouponType { GOLDEN_COUPON("황금"), DISCOUNT_COUPON("할인"); - private final String typeName; + private final String name; private static final Map COUPON_TYPE_MAP; static { COUPON_TYPE_MAP = Collections.unmodifiableMap(Arrays.stream(values()) - .collect(Collectors.toMap(CouponType::getTypeName, Function.identity()))); + .collect(Collectors.toMap(CouponType::getName, Function.identity()))); } - public static CouponType from(String typeName) { - return Optional.ofNullable(COUPON_TYPE_MAP.get(typeName)) + public static CouponType from(String name) { + return Optional.ofNullable(COUPON_TYPE_MAP.get(name)) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_TYPE)); } } diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java index 6c82e022..a7ab35ef 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java @@ -14,11 +14,11 @@ public class CouponQueueRepository { private final ZSetRedisRepository zSetRedisRepository; - public void addQueue(String couponName, String memberNickname, double score) { + public void addIfAbsent(String couponName, String memberNickname, double score) { zSetRedisRepository.addIfAbsent(requireNonNull(couponName), requireNonNull(memberNickname), score); } - public Long queueSize(String couponName) { + public Long size(String couponName) { return zSetRedisRepository.size(requireNonNull(couponName)); } } 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 index 7bbc3889..7a922d2a 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Repository; import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CouponStatusRequest; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -21,48 +21,48 @@ public class CouponSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public List findAllByStatus(LocalDateTime now, CouponSearchRequest request) { + public List findAllByStatus(LocalDateTime now, CouponStatusRequest couponStatus) { return jpaQueryFactory.selectFrom(coupon) - .where(filterCouponStatus(now, request)) + .where(filterStatus(now, couponStatus)) .fetch(); } - private BooleanExpression filterCouponStatus(LocalDateTime now, CouponSearchRequest request) { - if (request.couponOngoing() && request.couponNotStarted() && request.couponEnded()) { + private BooleanExpression filterStatus(LocalDateTime now, CouponStatusRequest couponStatus) { + if (couponStatus.ongoing() && couponStatus.notStarted() && couponStatus.ended()) { return null; } // 시작 전이거나 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (request.couponOngoing() && request.couponNotStarted()) { + if (couponStatus.ongoing() && couponStatus.notStarted()) { return (coupon.startAt.gt(now)) .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); } // 종료 됐거나 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (request.couponOngoing() && request.couponEnded()) { + if (couponStatus.ongoing() && couponStatus.ended()) { return (coupon.endAt.lt(now)) .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); } // 진행 중이 아니고, 시작 전이거나, 종료된 쿠폰들을 조회하고 싶은 경우 - if (request.couponNotStarted() && request.couponEnded()) { + if (couponStatus.notStarted() && couponStatus.ended()) { return coupon.startAt.gt(now) .or(coupon.endAt.lt(now)); } // 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (request.couponOngoing()) { + if (couponStatus.ongoing()) { return coupon.startAt.loe(now) .and(coupon.endAt.goe(now)); } // 시작 적인 쿠폰들을 조회하고 싶은 경우 - if (request.couponNotStarted()) { + if (couponStatus.notStarted()) { return coupon.startAt.gt(now); } // 종료된 쿠폰들을 조회하고 싶은 경우 - if (request.couponEnded()) { + if (couponStatus.ended()) { return coupon.endAt.lt(now); } 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 index df41d185..2701d9d8 100644 --- a/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java @@ -29,7 +29,7 @@ public void saveFcmToken(Long memberId, String fcmToken) { ); } - public void saveKnockNotification(String knockKey) { + public void saveKnock(String knockKey) { stringRedisRepository.save( requireNonNull(knockKey), BLANK, @@ -46,8 +46,8 @@ public String findFcmTokenByMemberId(Long memberId) { return stringRedisRepository.get(String.valueOf(requireNonNull(memberId))); } - public boolean existsByKey(String key) { - return stringRedisRepository.hasKey(requireNonNull(key)); + public boolean existsKnockByKnockKey(String knockKey) { + return stringRedisRepository.hasKey(requireNonNull(knockKey)); } public boolean existsFcmTokenByMemberId(Long memberId) { diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java index 3be5e025..0a323acd 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java +++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java @@ -9,13 +9,13 @@ @Builder public record CouponResponse( - Long couponId, - String couponAdminName, + Long id, + String adminName, String name, String description, int point, int stock, - CouponType couponType, + CouponType type, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") LocalDateTime startAt, @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java b/src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java deleted file mode 100644 index 8e8e1c2d..00000000 --- a/src/main/java/com/moabam/api/dto/coupon/CouponSearchRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.moabam.api.dto.coupon; - -import lombok.Builder; - -@Builder -public record CouponSearchRequest( - boolean couponOngoing, - boolean couponNotStarted, - boolean couponEnded -) { - -} 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..0c41fcdb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.coupon; + +import lombok.Builder; + +@Builder +public record CouponStatusRequest( + boolean ongoing, + boolean notStarted, + 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 index ade47498..245ff76e 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java +++ b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java @@ -15,7 +15,7 @@ public record CreateCouponRequest( @NotBlank(message = "쿠폰명이 입력되지 않았거나 20자를 넘었습니다.") @Length(max = 20) String name, @Length(max = 50, message = "쿠폰 간단 소개는 최대 50자까지 가능합니다.") String description, - @NotBlank(message = "쿠폰 종류를 입력해주세요.") String couponType, + @NotBlank(message = "쿠폰 종류를 입력해주세요.") String type, @Min(value = 1, message = "벌레 수 혹은 할인 금액은 1 이상이어야 합니다.") int point, @Min(value = 1, message = "쿠폰 재고는 1 이상이어야 합니다.") int stock, @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") diff --git a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java similarity index 75% rename from src/main/java/com/moabam/api/application/notification/NotificationMapper.java rename to src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java index 57e027c1..f977fc84 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationMapper.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java @@ -1,4 +1,4 @@ -package com.moabam.api.application.notification; +package com.moabam.api.infrastructure.fcm; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; @@ -7,7 +7,7 @@ import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class NotificationMapper { +public final class FcmMapper { private static final String NOTIFICATION_TITLE = "모아밤"; @@ -18,7 +18,7 @@ public static Notification toNotification(String body) { .build(); } - public static Message toMessageEntity(Notification notification, String fcmToken) { + public static Message toMessage(Notification notification, String fcmToken) { return Message.builder() .setNotification(notification) .setToken(fcmToken) diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java index e995688c..357be42d 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -5,7 +5,6 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; -import com.moabam.api.application.notification.NotificationMapper; import lombok.RequiredArgsConstructor; @@ -15,11 +14,11 @@ public class FcmService { private final FirebaseMessaging firebaseMessaging; - public void sendAsyncFcm(String fcmToken, String notificationBody) { - Notification notification = NotificationMapper.toNotification(notificationBody); + public void sendAsync(String fcmToken, String notificationBody) { + Notification notification = FcmMapper.toNotification(notificationBody); if (fcmToken != null) { - Message message = NotificationMapper.toMessageEntity(notification, fcmToken); + Message message = FcmMapper.toMessage(notification, fcmToken); firebaseMessaging.sendAsync(message); } } diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index a1aa4664..f37259e4 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -15,7 +15,7 @@ import com.moabam.api.application.coupon.CouponQueueService; import com.moabam.api.application.coupon.CouponService; import com.moabam.api.dto.coupon.CouponResponse; -import com.moabam.api.dto.coupon.CouponSearchRequest; +import com.moabam.api.dto.coupon.CouponStatusRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; import com.moabam.global.auth.annotation.CurrentMember; import com.moabam.global.auth.model.AuthorizationMember; @@ -32,27 +32,27 @@ public class CouponController { @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) - public void createCoupon(@CurrentMember AuthorizationMember admin, + public void create(@CurrentMember AuthorizationMember admin, @Valid @RequestBody CreateCouponRequest request) { - couponService.createCoupon(admin, request); + couponService.create(admin, request); } @DeleteMapping("/admins/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public void deleteCoupon(@CurrentMember AuthorizationMember admin, @PathVariable("couponId") Long couponId) { - couponService.deleteCoupon(admin, couponId); + public void delete(@CurrentMember AuthorizationMember admin, @PathVariable("couponId") Long couponId) { + couponService.delete(admin, couponId); } @GetMapping("/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public CouponResponse getCouponById(@PathVariable("couponId") Long couponId) { - return couponService.getCouponById(couponId); + public CouponResponse getById(@PathVariable("couponId") Long couponId) { + return couponService.getById(couponId); } @PostMapping("/coupons/search") @ResponseStatus(HttpStatus.OK) - public List getCoupons(@Valid @RequestBody CouponSearchRequest request) { - return couponService.getCoupons(request); + public List getAllByStatus(@Valid @RequestBody CouponStatusRequest request) { + return couponService.getAllByStatus(request); } @PostMapping("/coupons") diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java index 7ddb34f1..f83d74aa 100644 --- a/src/main/java/com/moabam/api/presentation/NotificationController.java +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -21,6 +21,6 @@ public class NotificationController { @GetMapping("/rooms/{roomId}/members/{memberId}") public void sendKnockNotification(@CurrentMember AuthorizationMember member, @PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { - notificationService.sendKnockNotification(member, memberId, roomId); + notificationService.sendKnock(member, memberId, roomId); } } diff --git a/src/main/resources/config b/src/main/resources/config index 2a1a59a1..2b721299 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2a1a59a16d8e868185c125a58aec0682f3c53f0d +Subproject commit 2b7212992e491c6d222f0b6a9b9289525be07008 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 1ac3df18..07dee99b 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -2132,13 +2132,13 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 192
+Content-Length: 186
 Host: localhost:8080
 
 {
   "name" : "couponName",
   "description" : "coupon description",
-  "couponType" : "황금",
+  "type" : "황금",
   "point" : 10,
   "stock" : 10,
   "startAt" : "2023-01-01T00:00",
@@ -2240,13 +2240,13 @@ 

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 84
+Content-Length: 66
 Host: localhost:8080
 
 {
-  "couponOngoing" : false,
-  "couponNotStarted" : false,
-  "couponEnded" : false
+  "ongoing" : false,
+  "notStarted" : false,
+  "ended" : false
 }
@@ -2324,10 +2324,9 @@

쿠폰 사용 (진행 중)

응답

-
HTTP/1.1 404 Not Found
+
HTTP/1.1 400 Bad Request
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 58
+Content-Length: 64
 
 {
-  "message" : "존재하지 않는 쿠폰입니다."
+  "message" : "쿠폰 발급 가능 기간이 아닙니다."
 }
@@ -655,7 +751,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 5742b799..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 583d6301..72056746 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -445,7 +445,7 @@

알림(Notification)

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

콕 찌르기 알림

요청

-
GET /notifications/rooms/3/members/3 HTTP/1.1
+
GET /notifications/rooms/4/members/4 HTTP/1.1
 Host: localhost:8080

응답

-
HTTP/1.1 409 Conflict
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 66
+Content-Length: 64
 
 {
-  "message" : "이미 콕 알림을 보낸 대상입니다."
+  "message" : "해당 유저는 접속 중이 아닙니다."
 }
+
+

FCM TOKEN 저장

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

요청

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

응답

+
+
+
HTTP/1.1 200 OK
+Vary: Origin
+Vary: Access-Control-Request-Method
+Vary: Access-Control-Request-Headers
+
+
+
diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 634e5d7c..60e7e65f 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -19,6 +19,7 @@ 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.FcmRepository; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; @@ -38,6 +39,9 @@ class NotificationServiceTest { @Mock private RoomService roomService; + @Mock + private FcmRepository fcmRepository; + @Mock private FcmService fcmService; @@ -53,14 +57,13 @@ class NotificationServiceTest { @WithMember @DisplayName("성공적으로 상대에게 콕 알림을 보낸다. - Void") @Test - void notificationService_sendKnockNotification() { + void sendKnock() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(true); - given(notificationRepository.existsKnockByKnockKey(any(String.class))).willReturn(false); - given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); // When notificationService.sendKnock(member, 2L, 1L); @@ -73,7 +76,7 @@ void notificationService_sendKnockNotification() { @WithMember @DisplayName("콕 찌를 상대의 방이 존재하지 않는다. - NotFoundException") @Test - void notificationService_sendKnockNotification_Room_NotFoundException() { + void sendKnock_Room_NotFoundException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); @@ -86,13 +89,14 @@ void notificationService_sendKnockNotification_Room_NotFoundException() { @WithMember @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") @Test - void notificationService_sendKnockNotification_FcmToken_NotFoundException() { + void sendKnock_FcmToken_NotFoundException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsKnockByKnockKey(any(String.class))).willReturn(false); - given(notificationRepository.existsFcmTokenByMemberId(any(Long.class))).willReturn(false); + given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); + given(fcmService.findTokenByMemberId(any(Long.class))) + .willThrow(new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); // When & Then assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) @@ -103,12 +107,12 @@ void notificationService_sendKnockNotification_FcmToken_NotFoundException() { @WithMember @DisplayName("콕 찌를 상대가 이미 찌른 상대이다. - ConflictException") @Test - void notificationService_sendKnockNotification_ConflictException() { + void sendKnock_ConflictException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsKnockByKnockKey(any(String.class))).willReturn(true); + given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(true); // When & Then assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) @@ -119,10 +123,10 @@ void notificationService_sendKnockNotification_ConflictException() { @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낸다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest - void notificationService_sendCertificationTimeNotification(List participants) { + void sendCertificationTime(List participants) { // Given given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); - given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); given(clockHolder.times()).willReturn(LocalDateTime.now()); // When @@ -136,10 +140,10 @@ void notificationService_sendCertificationTimeNotification(List par @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest - void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(List participants) { + void sendCertificationTime_NoFirebaseMessaging(List participants) { // Given given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); - given(notificationRepository.findFcmTokenByMemberId(any(Long.class))).willReturn(null); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(null); given(clockHolder.times()).willReturn(LocalDateTime.now()); // When @@ -150,37 +154,35 @@ void notificationService_sendCertificationTimeNotification_NoFirebaseMessaging(L } @WithMember - @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸다. - KnockNotificationStatusResponse") + @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸다. - List") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest - void notificationService_knocked_checkMyKnockNotificationStatusInRoom(List participants) { + void getMyKnockStatusInRoom_knocked(List participants) { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - given(notificationRepository.existsKnockByKnockKey(any(String.class))).willReturn(true); + given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(true); // When - List actual = - notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); + List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); // Then assertThat(actual).hasSize(2); } @WithMember - @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸 적이 없다. - KnockNotificationStatusResponse") + @DisplayName("특정 방에서 나 이외의 모든 사용자에게 콕 알림을 보낸 적이 없다. - List") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest - void notificationService_notKnocked_checkMyKnockNotificationStatusInRoom(List participants) { + void getMyKnockStatusInRoom_notKnocked(List participants) { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); // given - given(notificationRepository.existsKnockByKnockKey(any(String.class))).willReturn(false); + given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); // When - List actual = - notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); + List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); // Then assertThat(actual).isEmpty(); 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 index ad33c2d0..6a9a376d 100644 --- a/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java @@ -23,24 +23,6 @@ class NotificationRepositoryTest { @Mock private StringRedisRepository stringRedisRepository; - @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") - @Test - void notificationRepository_saveFcmToken() { - // When - notificationRepository.saveFcmToken(1L, "value1"); - - // Then - verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); - } - - @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 이다. - NullPointerException") - @Test - void notificationRepository_save_NullPointerException() { - // When & Then - assertThatThrownBy(() -> notificationRepository.saveFcmToken(null, "value")) - .isInstanceOf(NullPointerException.class); - } - @DisplayName("콕 알림이 성공적으로 저장된다. - Void") @Test void notificationRepository_saveKnockNotification() { @@ -59,65 +41,11 @@ void notificationRepository_saveKnockNotification_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") - @Test - void notificationRepository_deleteFcmTokenByMemberId() { - // When - notificationRepository.deleteFcmTokenByMemberId(1L); - - // Then - verify(stringRedisRepository).delete(any(String.class)); - } - - @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 이다. - NullPointerException") - @Test - void notificationRepository_deleteFcmTokenByMemberId_NullPointerException() { - // When & Then - assertThatThrownBy(() -> notificationRepository.deleteFcmTokenByMemberId(null)) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") - @Test - void notificationRepository_findFcmTokenByMemberId() { - // When - notificationRepository.findFcmTokenByMemberId(1L); - - // Then - verify(stringRedisRepository).get(any(String.class)); - } - - @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 이다. - NullPointerException") - @Test - void notificationRepository_findFcmTokenByMemberId_NullPointerException() { - // When & Then - assertThatThrownBy(() -> notificationRepository.findFcmTokenByMemberId(null)) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인한다. - Boolean") - @Test - void notificationRepository_existsFcmTokenByMemberId() { - // When - notificationRepository.existsFcmTokenByMemberId(1L); - - // Then - verify(stringRedisRepository).hasKey(any(String.class)); - } - - @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") - @Test - void notificationRepository_existsFcmTokenByMemberId_NullPointerException() { - // When & Then - assertThatThrownBy(() -> notificationRepository.existsFcmTokenByMemberId(null)) - .isInstanceOf(NullPointerException.class); - } - @DisplayName("콕 알림 여부 체크를 정상적으로 확인한다. - Boolean") @Test void notificationRepository_existsKnockByMemberId() { // When - notificationRepository.existsKnockByKnockKey("knock key"); + notificationRepository.existsKnockByKey("knock key"); // Then verify(stringRedisRepository).hasKey(any(String.class)); @@ -127,7 +55,7 @@ void notificationRepository_existsKnockByMemberId() { @Test void notificationRepository_existsKnockByMemberId_NullPointerException() { // When & Then - assertThatThrownBy(() -> notificationRepository.existsKnockByKnockKey(null)) + assertThatThrownBy(() -> notificationRepository.existsKnockByKey(null)) .isInstanceOf(NullPointerException.class); } } 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..4c6f4367 --- /dev/null +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java @@ -0,0 +1,98 @@ +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.StringRedisRepository; + +@ExtendWith(MockitoExtension.class) +class FcmRepositoryTest { + + @InjectMocks + private FcmRepository fcmRepository; + + @Mock + private StringRedisRepository stringRedisRepository; + + @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") + @Test + void saveToken() { + // When + fcmRepository.saveToken(1L, "value1"); + + // Then + verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + } + + @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 이다. - NullPointerException") + @Test + void saveToken_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.saveToken(null, "value")) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") + @Test + void deleteTokenByMemberId() { + // When + fcmRepository.deleteTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).delete(any(String.class)); + } + + @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 이다. - NullPointerException") + @Test + void deleteTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.deleteTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") + @Test + void findTokenByMemberId() { + // When + fcmRepository.findTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).get(any(String.class)); + } + + @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 이다. - NullPointerException") + @Test + void findTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.findTokenByMemberId(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인한다. - Boolean") + @Test + void existsTokenByMemberId() { + // When + fcmRepository.existsTokenByMemberId(1L); + + // Then + verify(stringRedisRepository).hasKey(any(String.class)); + } + + @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") + @Test + void existsTokenByMemberId_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.existsTokenByMemberId(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 index 3c067fb8..218e4ef7 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -1,6 +1,6 @@ package com.moabam.api.infrastructure.fcm; -import static org.mockito.Mockito.*; +import static org.mockito.BDDMockito.*; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,10 +10,14 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; +import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.config.FcmConfig; +import com.moabam.support.annotation.WithMember; +import com.moabam.support.common.WithoutFilterSupporter; @SpringBootTest(classes = {FcmConfig.class, FcmService.class}) -class FcmServiceTest { +class FcmServiceTest extends WithoutFilterSupporter { @Autowired private FcmService fcmService; @@ -21,9 +25,74 @@ class FcmServiceTest { @MockBean private FirebaseMessaging firebaseMessaging; + @MockBean + private FcmRepository fcmRepository; + + @WithMember + @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") + @Test + void saveToken() { + // Given + AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); + + // When + fcmService.createToken(authMember, "value1"); + + // Then + verify(fcmRepository).saveToken(any(Long.class), any(String.class)); + } + + @WithMember + @DisplayName("FCM 토큰으로 빈값이 넘어와 아무일도 일어나지 않는다. - Void") + @Test + void saveToken_Blank() { + // Given + AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); + + // When + fcmService.createToken(authMember, ""); + + // Then + verify(fcmRepository, times(0)).saveToken(any(Long.class), any(String.class)); + } + + @WithMember + @DisplayName("FCM 토큰으로 null이 넘어와 아무일도 일어나지 않는다. - Void") + @Test + void saveToken_Null() { + // Given + AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); + + // When + fcmService.createToken(authMember, null); + + // Then + verify(fcmRepository, times(0)).saveToken(any(Long.class), any(String.class)); + } + + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") + @Test + void deleteTokenByMemberId() { + // When + fcmRepository.deleteTokenByMemberId(1L); + + // Then + verify(fcmRepository).deleteTokenByMemberId(any(Long.class)); + } + + @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") + @Test + void findTokenByMemberId() { + // When + fcmRepository.findTokenByMemberId(1L); + + // Then + verify(fcmRepository).findTokenByMemberId(any(Long.class)); + } + @DisplayName("비동기 FCM 알림을 성공적으로 보낸다. - Void") @Test - void fcmService_sendAsyncFcm() { + void sendAsync() { // When fcmService.sendAsync("FCM-TOKEN", "알림"); @@ -31,9 +100,9 @@ void fcmService_sendAsyncFcm() { verify(firebaseMessaging).sendAsync(any(Message.class)); } - @DisplayName("FCM 토큰이 null이여서 비동기 FCM 알림을 보내지. - Void") + @DisplayName("FCM 토큰이 null이여서 비동기 FCM 알림을 보내지 않는다. - Void") @Test - void fcmService_sendAsyncFcm_null() { + void sendAsync_null() { // When fcmService.sendAsync(null, "알림"); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index ec9a4afb..e239880c 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -59,9 +59,9 @@ class CouponControllerTest extends WithoutFilterSupporter { private ClockHolder clockHolder; @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰을 성공적으로 발행한다. - Void") + @DisplayName("POST - 쿠폰을 성공적으로 발행한다. - Void") @Test - void couponController_createCoupon() throws Exception { + void create_Coupon() throws Exception { // Given String couponType = CouponType.GOLDEN_COUPON.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); @@ -79,9 +79,9 @@ void couponController_createCoupon() throws Exception { } @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰 발급 종료기간 시작기간보다 이전인 쿠폰을 발행한다. - BadRequestException") + @DisplayName("POST - 쿠폰 발급 종료기간 시작기간보다 이전인 쿠폰을 발행한다. - BadRequestException") @Test - void couponController_createCoupon_BadRequestException() throws Exception { + void create_Coupon_BadRequestException() throws Exception { // Given String couponType = CouponType.GOLDEN_COUPON.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 1); @@ -102,9 +102,9 @@ void couponController_createCoupon_BadRequestException() throws Exception { } @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") + @DisplayName("POST - 쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") @Test - void couponController_createCoupon_ConflictException() throws Exception { + void create_Coupon_ConflictException() throws Exception { // Given String couponType = CouponType.GOLDEN_COUPON.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); @@ -126,9 +126,9 @@ void couponController_createCoupon_ConflictException() throws Exception { } @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰을 성공적으로 삭제한다. - Void") + @DisplayName("DELETE - 쿠폰을 성공적으로 삭제한다. - Void") @Test - void couponController_deleteCoupon() throws Exception { + void delete_Coupon() throws Exception { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); @@ -142,9 +142,9 @@ void couponController_deleteCoupon() throws Exception { } @WithMember(role = Role.ADMIN) - @DisplayName("존재하지 않는 쿠폰을 삭제한다. - NotFoundException") + @DisplayName("DELETE - 존재하지 않는 쿠폰을 삭제한다. - NotFoundException") @Test - void couponController_deleteCoupon_NotFoundException() throws Exception { + void delete_Coupon_NotFoundException() throws Exception { // When & Then mockMvc.perform(delete("/admins/coupons/77777777777")) .andDo(print()) @@ -157,9 +157,9 @@ void couponController_deleteCoupon_NotFoundException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); } - @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") + @DisplayName("GET - 특정 쿠폰을 조회한다. - CouponResponse") @Test - void couponController_getCouponById() throws Exception { + void getById_Coupon() throws Exception { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); @@ -175,9 +175,9 @@ void couponController_getCouponById() throws Exception { .andExpect(jsonPath("$.id").value(coupon.getId())); } - @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") + @DisplayName("GET - 존재하지 않는 쿠폰을 조회한다. - NotFoundException") @Test - void couponController_getCouponById_NotFoundException() throws Exception { + void getById_Coupon_NotFoundException() throws Exception { // When & Then mockMvc.perform(get("/coupons/77777777777")) .andDo(print()) @@ -190,10 +190,10 @@ void couponController_getCouponById_NotFoundException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); } - @DisplayName("모든 쿠폰을 조회한다. - List") + @DisplayName("POST - 모든 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponController_getCoupons(List coupons) throws Exception { + void getAllByStatus_Coupons(List coupons) throws Exception { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true, true); List coupon = couponRepository.saveAll(coupons); @@ -213,10 +213,10 @@ void couponController_getCoupons(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(coupon.size()))); } - @DisplayName("상태 조건을 걸지 않아서 쿠폰이 조회되지 않는다. - List") + @DisplayName("POST - 상태 조건을 걸지 않아서 쿠폰이 조회되지 않는다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponController_getCoupons_not_status(List coupons) throws Exception { + void getAllByStatus_Coupons_not_status(List coupons) throws Exception { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false, false); couponRepository.saveAll(coupons); @@ -236,9 +236,9 @@ void couponController_getCoupons_not_status(List coupons) throws Excepti } @WithMember(nickname = "member-coupon-1") - @DisplayName("쿠폰 발급 요청을 한다. - Void") + @DisplayName("POST - 쿠폰 발급 요청을 한다. - Void") @Test - void couponController_registerCouponQueue() throws Exception { + void registerQueue() throws Exception { // Given Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); @@ -257,9 +257,9 @@ void couponController_registerCouponQueue() throws Exception { } @WithMember(nickname = "member-coupon-2") - @DisplayName("발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") + @DisplayName("POST - 발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") @Test - void couponController_registerCouponQueue_BadRequestException() throws Exception { + void registerQueue_BadRequestException() throws Exception { // Given Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 1); @@ -281,9 +281,9 @@ void couponController_registerCouponQueue_BadRequestException() throws Exception } @WithMember - @DisplayName("존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") + @DisplayName("POST - 존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") @Test - void couponController_registerCouponQueue_NotFoundException() throws Exception { + void registerQueue_NotFoundException() throws Exception { // Given Coupon coupon = CouponFixture.coupon("Not found coupon name", 1, 2); LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 4ed792eb..a1d00129 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -27,6 +27,8 @@ 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.StringRedisRepository; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; @@ -58,6 +60,12 @@ class NotificationControllerTest extends WithoutFilterSupporter { @Autowired private StringRedisRepository stringRedisRepository; + @Autowired + private FcmService fcmService; + + @Autowired + private FcmRepository fcmRepository; + @MockBean private FirebaseMessaging firebaseMessaging; @@ -78,16 +86,44 @@ void setUp() { @AfterEach void setDown() { - notificationRepository.deleteFcmTokenByMemberId(target.getId()); + fcmService.deleteTokenByMemberId(target.getId()); stringRedisRepository.delete(knockKey); } + @WithMember + @DisplayName("POST - 성공적으로 FCM Token을 저장한다. - Void") + @Test + void createFcmToken() 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 notificationController_sendKnockNotification() throws Exception { + void sendKnock() throws Exception { // Given - notificationRepository.saveFcmToken(target.getId(), "FCM_TOKEN"); + fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) @@ -101,7 +137,7 @@ void notificationController_sendKnockNotification() throws Exception { @WithMember @DisplayName("GET - 콕 알림을 보낸 상대가 접속 중이 아니다. - NotFoundException") @Test - void notificationController_sendKnockNotification_NotFoundException() throws Exception { + void sendKnock_NotFoundException() throws Exception { // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) .andDo(print()) @@ -117,9 +153,9 @@ void notificationController_sendKnockNotification_NotFoundException() throws Exc @WithMember @DisplayName("GET - 이미 콕 알림을 보낸 대상이다. - ConflictException") @Test - void notificationController_sendKnockNotification_ConflictException() throws Exception { + void sendKnock_ConflictException() throws Exception { // Given - notificationRepository.saveFcmToken(target.getId(), "FCM_TOKEN"); + fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); notificationRepository.saveKnock(knockKey); // When & Then From f1e51bca1010a0699f195dac33c36bd416abfbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Tue, 21 Nov 2023 18:07:25 +0900 Subject: [PATCH 074/185] =?UTF-8?q?feat:=20CouponWallet=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20&=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=ED=98=84=20(#134)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/coupon/CouponWallet.java | 42 +++++++++++++++++++ .../repository/CouponWalletRepository.java | 9 ++++ .../api/domain/coupon/CouponWalletTest.java | 28 +++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 src/main/java/com/moabam/api/domain/coupon/CouponWallet.java create mode 100644 src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java create mode 100644 src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java 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..da0ff343 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java @@ -0,0 +1,42 @@ +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.Builder; +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; + + @Builder + private CouponWallet(Long memberId, Coupon coupon) { + this.memberId = memberId; + this.coupon = coupon; + } +} 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..626512dc --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.coupon.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.coupon.CouponWallet; + +public interface CouponWalletRepository extends JpaRepository { + +} 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..d1b7ee46 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java @@ -0,0 +1,28 @@ +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() { + // Given + Coupon coupon = CouponFixture.coupon("CouponName", 1, 2); + + // When + CouponWallet actual = CouponWallet.builder() + .memberId(1L) + .coupon(coupon) + .build(); + + // Then + assertThat(actual.getMemberId()).isEqualTo(1L); + assertThat(actual.getCoupon().getName()).isEqualTo(coupon.getName()); + } +} From 12275e0144125538b7347bc702927a1ff7b9a1e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Thu, 23 Nov 2023 11:21:36 +0900 Subject: [PATCH 075/185] =?UTF-8?q?refactor=20:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=20=EA=B8=B0=EA=B0=84=20=ED=95=98=EB=A3=A8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=BF=A0=ED=8F=B0?= =?UTF-8?q?=20=EC=A0=95=EB=B3=B4=20=EC=98=A4=ED=94=88=20=EB=82=A0=EC=A7=9C?= =?UTF-8?q?=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20(#136)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style : Schedule 어노테이션 위치 변경 * refactor: 쿠폰 발행 기간 하루로 통일 및 쿠폰 정보 오픈 날짜 추가 * refactor: Sub module Update --- .../api/application/coupon/CouponMapper.java | 4 +- .../api/application/coupon/CouponService.java | 26 ++-- .../com/moabam/api/domain/coupon/Coupon.java | 16 +-- .../repository/CouponSearchRepository.java | 48 ++----- .../moabam/api/dto/coupon/CouponResponse.java | 10 +- .../api/dto/coupon/CouponStatusRequest.java | 3 +- .../api/dto/coupon/CreateCouponRequest.java | 10 +- .../com/moabam/global/config/FcmConfig.java | 2 - .../com/moabam/global/config/WebConfig.java | 2 + .../global/error/model/ErrorMessage.java | 5 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 121 +++--------------- .../resources/static/docs/notification.html | 2 +- .../coupon/CouponQueueServiceTest.java | 15 ++- .../application/coupon/CouponServiceTest.java | 83 +++++++----- .../moabam/api/domain/coupon/CouponTest.java | 10 +- .../CouponSearchRepositoryTest.java | 110 ++++------------ .../dto/coupon/CreateCouponRequestTest.java | 8 +- .../presentation/CouponControllerTest.java | 72 +++++++---- .../moabam/support/fixture/CouponFixture.java | 75 +++++++---- .../support/fixture/CouponSnippetFixture.java | 17 ++- 21 files changed, 270 insertions(+), 371 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index f61a6c3c..d38dbb77 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -19,7 +19,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { .point(coupon.point()) .stock(coupon.stock()) .startAt(coupon.startAt()) - .endAt(coupon.endAt()) + .openAt(coupon.openAt()) .adminId(adminId) .build(); } @@ -35,7 +35,7 @@ public static CouponResponse toDto(Coupon coupon) { .stock(coupon.getStock()) .type(coupon.getType()) .startAt(coupon.getStartAt()) - .endAt(coupon.getEndAt()) + .openAt(coupon.getOpenAt()) .build(); } } diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index f0f72f34..0d080df4 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -1,6 +1,6 @@ package com.moabam.api.application.coupon; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Service; @@ -35,7 +35,7 @@ public class CouponService { public void create(AuthMember admin, CreateCouponRequest request) { validateAdminRole(admin); validateConflictName(request.name()); - validatePeriod(request.startAt(), request.endAt()); + validatePeriod(request.startAt(), request.openAt()); Coupon coupon = CouponMapper.toEntity(admin.id(), request); couponRepository.save(coupon); @@ -57,7 +57,7 @@ public CouponResponse getById(Long couponId) { } public List getAllByStatus(CouponStatusRequest request) { - LocalDateTime now = clockHolder.times(); + LocalDate now = LocalDate.from(clockHolder.times()); List coupons = couponSearchRepository.findAllByStatus(now, request); return coupons.stream() @@ -66,20 +66,26 @@ public List getAllByStatus(CouponStatusRequest request) { } public Coupon validatePeriod(String couponName) { - LocalDateTime now = clockHolder.times(); + LocalDate now = LocalDate.from(clockHolder.times()); Coupon coupon = couponRepository.findByName(couponName) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); - if (!now.isBefore(coupon.getStartAt()) && !now.isAfter(coupon.getEndAt())) { - return coupon; + if (!now.equals(coupon.getStartAt())) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); } - throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD_END); + return coupon; } - private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { - if (startAt.isAfter(endAt)) { - throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); + private void validatePeriod(LocalDate startAt, LocalDate openAt) { + LocalDate now = LocalDate.from(clockHolder.times()); + + 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); } } diff --git a/src/main/java/com/moabam/api/domain/coupon/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java index c3e5c2be..daeda469 100644 --- a/src/main/java/com/moabam/api/domain/coupon/Coupon.java +++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java @@ -4,7 +4,7 @@ import static com.moabam.global.error.model.ErrorMessage.*; import static java.util.Objects.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.Optional; import org.hibernate.annotations.ColumnDefault; @@ -56,25 +56,25 @@ public class Coupon extends BaseTimeEntity { private int stock; @Column(name = "start_at", nullable = false) - private LocalDateTime startAt; + private LocalDate startAt; - @Column(name = "end_at", nullable = false) - private LocalDateTime endAt; + @Column(name = "open_at", nullable = false) + private LocalDate openAt; // TODO : 관리자 테이블 생기면 관리자 테이블이랑 다대일 관계 맺을 예정 @Column(name = "admin_id", updatable = false, nullable = false) private Long adminId; @Builder - private Coupon(String name, int point, String description, CouponType type, int stock, LocalDateTime startAt, - LocalDateTime endAt, Long adminId) { + private Coupon(String name, String description, int point, int stock, CouponType type, LocalDate startAt, + LocalDate openAt, Long adminId) { this.name = requireNonNull(name); this.point = validatePoint(point); this.description = Optional.ofNullable(description).orElse(BLANK); this.type = requireNonNull(type); this.stock = validateStock(stock); this.startAt = requireNonNull(startAt); - this.endAt = requireNonNull(endAt); + this.openAt = requireNonNull(openAt); this.adminId = requireNonNull(adminId); } @@ -96,6 +96,6 @@ private int validateStock(int stock) { @Override public String toString() { - return "Coupon{startAt=" + startAt + ", endAt=" + endAt + '}'; + return String.format("Coupon{startAt=%s, openAt=%s}", startAt, openAt); } } 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 index 7a922d2a..703847eb 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponSearchRepository.java @@ -2,7 +2,7 @@ import static com.moabam.api.domain.coupon.QCoupon.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import org.springframework.stereotype.Repository; @@ -10,7 +10,6 @@ import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.dto.coupon.CouponStatusRequest; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.Expressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -21,51 +20,30 @@ public class CouponSearchRepository { private final JPAQueryFactory jpaQueryFactory; - public List findAllByStatus(LocalDateTime now, CouponStatusRequest couponStatus) { + public List findAllByStatus(LocalDate now, CouponStatusRequest couponStatus) { return jpaQueryFactory.selectFrom(coupon) .where(filterStatus(now, couponStatus)) + .orderBy(coupon.startAt.asc()) .fetch(); } - private BooleanExpression filterStatus(LocalDateTime now, CouponStatusRequest couponStatus) { - if (couponStatus.ongoing() && couponStatus.notStarted() && couponStatus.ended()) { + private BooleanExpression filterStatus(LocalDate now, CouponStatusRequest couponStatus) { + // 모든 쿠폰 (금일 발급 가능한 쿠폰 포함) + if (couponStatus.opened() && couponStatus.ended()) { return null; } - // 시작 전이거나 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (couponStatus.ongoing() && couponStatus.notStarted()) { - return (coupon.startAt.gt(now)) - .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); + // 쿠폰 정보 오픈 중인 쿠폰들 (금일 발급 가능한 쿠폰 포함) + if (couponStatus.opened()) { + return coupon.openAt.loe(now).and(coupon.startAt.goe(now)); } - // 종료 됐거나 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (couponStatus.ongoing() && couponStatus.ended()) { - return (coupon.endAt.lt(now)) - .or(coupon.startAt.loe(now).and(coupon.endAt.goe(now))); - } - - // 진행 중이 아니고, 시작 전이거나, 종료된 쿠폰들을 조회하고 싶은 경우 - if (couponStatus.notStarted() && couponStatus.ended()) { - return coupon.startAt.gt(now) - .or(coupon.endAt.lt(now)); - } - - // 진행 중인 쿠폰들을 조회하고 싶은 경우 - if (couponStatus.ongoing()) { - return coupon.startAt.loe(now) - .and(coupon.endAt.goe(now)); - } - - // 시작 적인 쿠폰들을 조회하고 싶은 경우 - if (couponStatus.notStarted()) { - return coupon.startAt.gt(now); - } - - // 종료된 쿠폰들을 조회하고 싶은 경우 + // 종료된 쿠폰들 if (couponStatus.ended()) { - return coupon.endAt.lt(now); + return coupon.startAt.lt(now); } - return Expressions.FALSE; + // 금일 발급 가능한 쿠폰 + return coupon.startAt.eq(now); } } diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java index 0a323acd..ac408733 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java +++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java @@ -1,6 +1,6 @@ package com.moabam.api.dto.coupon; -import java.time.LocalDateTime; +import java.time.LocalDate; import com.fasterxml.jackson.annotation.JsonFormat; import com.moabam.api.domain.coupon.CouponType; @@ -16,10 +16,10 @@ public record CouponResponse( int point, int stock, CouponType type, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime startAt, - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm") - LocalDateTime endAt + @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 index 0c41fcdb..0cecaea2 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java +++ b/src/main/java/com/moabam/api/dto/coupon/CouponStatusRequest.java @@ -4,8 +4,7 @@ @Builder public record CouponStatusRequest( - boolean ongoing, - boolean notStarted, + 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 index 245ff76e..02ebc0c7 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java +++ b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java @@ -1,6 +1,6 @@ package com.moabam.api.dto.coupon; -import java.time.LocalDateTime; +import java.time.LocalDate; import org.hibernate.validator.constraints.Length; @@ -18,10 +18,10 @@ public record CreateCouponRequest( @NotBlank(message = "쿠폰 종류를 입력해주세요.") String type, @Min(value = 1, message = "벌레 수 혹은 할인 금액은 1 이상이어야 합니다.") int point, @Min(value = 1, message = "쿠폰 재고는 1 이상이어야 합니다.") int stock, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") - @NotNull(message = "쿠폰 발급 시작 시각을 입력해주세요.") LocalDateTime startAt, - @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm") - @NotNull(message = "쿠폰 발급 종료 시각을 입력해주세요.") LocalDateTime endAt + @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/global/config/FcmConfig.java b/src/main/java/com/moabam/global/config/FcmConfig.java index 115e6091..6f003820 100644 --- a/src/main/java/com/moabam/global/config/FcmConfig.java +++ b/src/main/java/com/moabam/global/config/FcmConfig.java @@ -6,7 +6,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.io.ClassPathResource; -import org.springframework.scheduling.annotation.EnableScheduling; import com.google.auth.oauth2.GoogleCredentials; import com.google.firebase.FirebaseApp; @@ -19,7 +18,6 @@ @Slf4j @Configuration -@EnableScheduling public class FcmConfig { private static final String FIREBASE_PATH = "config/moabam-firebase.json"; diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index e2296881..b72e98af 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -6,6 +6,7 @@ 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.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @@ -15,6 +16,7 @@ import com.moabam.global.auth.handler.PathResolver; @Configuration +@EnableScheduling public class WebConfig implements WebMvcConfigurer { private static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 351450fe..a0fdf88e 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -63,8 +63,9 @@ public enum ErrorMessage { INVALID_COUPON_POINT("쿠폰의 보너스 포인트는 0 이상이어야 합니다."), INVALID_COUPON_STOCK("쿠폰의 재고는 0 이상이어야 합니다."), INVALID_COUPON_STOCK_END("쿠폰 발급 선착순이 마감되었습니다."), - INVALID_COUPON_PERIOD("쿠폰 발급 종료 시각은 시작 시각보다 이후여야 합니다."), - INVALID_COUPON_PERIOD_END("쿠폰 발급 가능 기간이 아닙니다."), + INVALID_COUPON_START_AT_PERIOD("쿠폰 발급 시작 날짜는 현재 날짜보다 이전이거나 같을 수 없습니다."), + INVALID_COUPON_OPEN_AT_PERIOD("쿠폰 정보 오픈 날짜는 시작 날짜보다 이전이여야 합니다."), + INVALID_COUPON_PERIOD("쿠폰 발급 가능 기간이 아닙니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), diff --git a/src/main/resources/config b/src/main/resources/config index 2a1a59a1..35c04d25 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2a1a59a16d8e868185c125a58aec0682f3c53f0d +Subproject commit 35c04d25c466b163ffceaf81b5d7e8855b78d7ec diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index c8ede223..df366758 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 186
+Content-Length: 175
 Host: localhost:8080
 
 {
@@ -470,8 +470,8 @@ 

요청

"type" : "황금", "point" : 10, "stock" : 10, - "startAt" : "2023-01-01T00:00", - "endAt" : "2023-02-01T00:00" + "startAt" : "2023-02-01", + "openAt" : "2023-01-01" }
@@ -496,7 +496,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/11 HTTP/1.1
+
DELETE /admins/coupons/1 HTTP/1.1
 Host: localhost:8080
@@ -534,7 +534,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 216 +Content-Length: 205 { "id" : 26, @@ -544,8 +544,8 @@

응답

"point" : 10, "stock" : 100, "type" : "MORNING_COUPON", - "startAt" : "2023-01-01T00:00", - "endAt" : "2023-02-01T00:00" + "startAt" : "2023-02-01", + "openAt" : "2023-01-01" }
@@ -565,13 +565,12 @@

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 63
+Content-Length: 41
 Host: localhost:8080
 
 {
-  "ongoing" : true,
-  "notStarted" : true,
-  "ended" : true
+  "opened" : false,
+  "ended" : false
 }
@@ -583,108 +582,18 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 2153 +Content-Length: 206 [ { - "id" : 14, - "adminName" : "1admin", - "name" : "coupon1", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-01-01T00:00", - "endAt" : "2023-03-01T00:00" -}, { "id" : 15, "adminName" : "1admin", - "name" : "coupon2", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-02-01T00:00", - "endAt" : "2023-04-01T00:00" -}, { - "id" : 16, - "adminName" : "1admin", - "name" : "coupon3", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-03-01T00:00", - "endAt" : "2023-05-01T00:00" -}, { - "id" : 17, - "adminName" : "1admin", - "name" : "coupon4", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-04-01T00:00", - "endAt" : "2023-06-01T00:00" -}, { - "id" : 18, - "adminName" : "1admin", - "name" : "coupon5", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-05-01T00:00", - "endAt" : "2023-07-01T00:00" -}, { - "id" : 19, - "adminName" : "1admin", - "name" : "coupon6", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-06-01T00:00", - "endAt" : "2023-08-01T00:00" -}, { - "id" : 20, - "adminName" : "1admin", - "name" : "coupon7", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-07-01T00:00", - "endAt" : "2023-09-01T00:00" -}, { - "id" : 21, - "adminName" : "1admin", - "name" : "coupon8", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-08-01T00:00", - "endAt" : "2023-10-01T00:00" -}, { - "id" : 22, - "adminName" : "1admin", - "name" : "coupon9", - "description" : "", - "point" : 10, - "stock" : 100, - "type" : "MORNING_COUPON", - "startAt" : "2023-09-01T00:00", - "endAt" : "2023-11-01T00:00" -}, { - "id" : 23, - "adminName" : "1admin", - "name" : "coupon10", + "name" : "coupon1", "description" : "", "point" : 10, "stock" : 100, "type" : "MORNING_COUPON", - "startAt" : "2023-10-01T00:00", - "endAt" : "2023-12-01T00:00" + "startAt" : "2023-03-01", + "openAt" : "2023-01-01" } ]
@@ -707,7 +616,7 @@

요청

Host: localhost:8080 Content-Length: 21 -couponName=CouponName
+couponName=couponName

응답

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 72056746..67060c84 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -515,7 +515,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java index cd33635d..b6721d68 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java @@ -35,10 +35,10 @@ class CouponQueueServiceTest { @WithMember @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") @Test - void couponQueueService_register() { + void register() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + Coupon coupon = CouponFixture.coupon(); given(couponService.validatePeriod(any(String.class))).willReturn(coupon); given(couponQueueRepository.size(any(String.class))).willReturn(coupon.getStock() - 1L); @@ -53,25 +53,26 @@ void couponQueueService_register() { @WithMember @DisplayName("해당 쿠폰은 발급 가능 기간이 아니다. - BadRequestException") @Test - void couponQueueService_register_BadRequestException() { + void register_BadRequestException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); + given(couponService.validatePeriod(any(String.class))) - .willThrow(new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD_END)); + .willThrow(new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD)); // When & Then assertThatThrownBy(() -> couponQueueService.register(member, "couponName")) .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage()); + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } @WithMember @DisplayName("해당 쿠폰은 마감된 쿠폰이다. - Void") @Test - void couponQueueService_register_End() { + void register_End() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - Coupon coupon = CouponFixture.coupon("couponName", 1, 2); + Coupon coupon = CouponFixture.coupon(); given(couponService.validatePeriod(any(String.class))).willReturn(coupon); given(couponQueueRepository.size(any(String.class))).willReturn((long)coupon.getStock()); diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 266d5915..18099a8c 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -53,13 +54,13 @@ class CouponServiceTest { @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test - void couponService_createCoupon() { + void create() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When couponService.create(admin, request); @@ -71,11 +72,10 @@ void couponService_createCoupon() { @WithMember(role = Role.USER) @DisplayName("권한 없는 사용자가 쿠폰을 발행한다. - NotFoundException") @Test - void couponService_createCoupon_Admin_NotFoundException() { + void create_Admin_NotFoundException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) @@ -83,14 +83,30 @@ void couponService_createCoupon_Admin_NotFoundException() { .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); } + @WithMember(role = Role.ADMIN) + @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") + @Test + void create_Type_NotFoundException() { + // Given + AuthMember admin = AuthorizationThreadLocal.getAuthMember(); + CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 2, 1); + + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + + // When & Then + assertThatThrownBy(() -> couponService.create(admin, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + } + @WithMember(role = Role.ADMIN) @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") @Test - void couponService_createCoupon_ConflictException() { + void create_ConflictException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); given(couponRepository.existsByName(any(String.class))).willReturn(true); @@ -101,40 +117,44 @@ void couponService_createCoupon_ConflictException() { } @WithMember(role = Role.ADMIN) - @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") + @DisplayName("현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") @Test - void couponService_createCoupon_NotFoundException() { + void create_StartAt_BadRequestException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(clockHolder.times()).willReturn(LocalDateTime.of(2025, 1, 1, 1, 1)); given(couponRepository.existsByName(any(String.class))).willReturn(false); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) - .isInstanceOf(NotFoundException.class) - .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_START_AT_PERIOD.getMessage()); } @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰 발급 종료 기간이 시작 기간보다 더 이전인 쿠폰을 발행한다. - BadRequestException") + @DisplayName("쿠폰 정보 오픈 날짜가 쿠폰 발급 시작 날짜와 같거나 이후인 쿠폰을 발행한다. - BadRequestException") @Test - void couponService_createCoupon_BadRequestException() { + void create_OpenAt_BadRequestException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 1); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); + given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); + .hasMessage(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD.getMessage()); } @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 삭제한다. - Void") @Test - void couponService_deleteCoupon() { + void delete() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(10, 100); @@ -150,7 +170,7 @@ void couponService_deleteCoupon() { @WithMember(role = Role.USER) @DisplayName("권한 없는 사용자가 쿠폰을 삭제한다. - NotFoundException") @Test - void couponService_deleteCoupon_Admin_NotFoundException() { + void delete_Admin_NotFoundException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); @@ -163,7 +183,7 @@ void couponService_deleteCoupon_Admin_NotFoundException() { @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰 아이디를 삭제하려고 시도한다. - NotFoundException") @Test - void couponService_deleteCoupon_NotFoundException() { + void delete_NotFoundException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); @@ -176,7 +196,7 @@ void couponService_deleteCoupon_NotFoundException() { @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") @Test - void couponService_getCouponById() { + void getById() { // Given Coupon coupon = CouponFixture.coupon(10, 100); given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); @@ -191,7 +211,7 @@ void couponService_getCouponById() { @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") @Test - void couponService_getCouponById_NotFoundException() { + void getById_NotFoundException() { // Given given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); @@ -204,12 +224,13 @@ void couponService_getCouponById_NotFoundException() { @DisplayName("모든 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponService_getCoupons(List coupons) { + void getAllByStatus(List coupons) { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true, true); - given(couponSearchRepository.findAllByStatus(any(LocalDateTime.class), any(CouponStatusRequest.class))) - .willReturn(coupons); + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponSearchRepository.findAllByStatus(any(LocalDate.class), any(CouponStatusRequest.class))) + .willReturn(coupons); // When List actual = couponService.getAllByStatus(request); @@ -220,7 +241,7 @@ void couponService_getCoupons(List coupons) { @DisplayName("해당 쿠폰은 발급 가능 기간입니다. - Coupon") @Test - void couponService_validateCouponPeriod() { + void validatePeriod() { // Given LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 0); Coupon coupon = CouponFixture.coupon("couponName", 1, 2); @@ -236,7 +257,7 @@ void couponService_validateCouponPeriod() { @DisplayName("해당 쿠폰은 발급 가능 기간이 아닙니다. - BadRequestException") @Test - void couponService_validateCouponPeriod_BadRequestException() { + void validatePeriod_BadRequestException() { // Given LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); Coupon coupon = CouponFixture.coupon("couponName", 1, 2); @@ -246,12 +267,12 @@ void couponService_validateCouponPeriod_BadRequestException() { // When & Then assertThatThrownBy(() -> couponService.validatePeriod("couponName")) .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage()); + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } @DisplayName("해당 쿠폰은 존재하지 않습니다. - NotFoundException") @Test - void couponService_validateCouponPeriod_NotFoundException() { + void validatePeriod_NotFoundException() { // Given LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); given(couponRepository.findByName(any(String.class))).willReturn(Optional.empty()); diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java index 29d27f45..2ab0e438 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,8 +17,8 @@ class CouponTest { @Test void coupon() { // Given - LocalDateTime startAt = LocalDateTime.of(2000, 1, 22, 10, 30, 0); - LocalDateTime endAt = LocalDateTime.of(2000, 1, 22, 11, 0, 0); + LocalDate startAt = LocalDate.of(2023, 2, 1); + LocalDate openAt = LocalDate.of(2023, 1, 1); // When Coupon actual = Coupon.builder() @@ -27,7 +27,7 @@ void coupon() { .type(CouponType.MORNING_COUPON) .stock(100) .startAt(startAt) - .endAt(endAt) + .openAt(openAt) .adminId(1L) .build(); @@ -38,7 +38,7 @@ void coupon() { assertThat(actual.getStock()).isEqualTo(100); assertThat(actual.getType()).isEqualTo(CouponType.MORNING_COUPON); assertThat(actual.getStartAt()).isEqualTo(startAt); - assertThat(actual.getEndAt()).isEqualTo(endAt); + assertThat(actual.getOpenAt()).isEqualTo(openAt); assertThat(actual.getAdminId()).isEqualTo(1L); } 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 index 92994bd1..22deb4da 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -27,13 +27,13 @@ class CouponSearchRepositoryTest { @Autowired private CouponSearchRepository couponSearchRepository; - @DisplayName("모든 쿠폰을 조회한다. - List") + @DisplayName("발급 가능한 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponSearchRepository_findAllByStatus(List coupons) { + void findAllByStatus(List coupons) { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true, true); - LocalDateTime now = LocalDateTime.now(); + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); + LocalDate now = LocalDate.of(2023, 7, 1); couponRepository.saveAll(coupons); @@ -41,50 +41,17 @@ void couponSearchRepository_findAllByStatus(List coupons) { List actual = couponSearchRepository.findAllByStatus(now, request); // Then - assertThat(actual).hasSize(coupons.size()); - } - - @DisplayName("시작 전이거나 진행 중인 쿠폰들을 조회한다. - List") - @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") - @ParameterizedTest - void couponSearchRepository_findAllByStatus_and_ongoing_notStarted(List coupons) { - // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true, false); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); - - couponRepository.saveAll(coupons); - - // When - List actual = couponSearchRepository.findAllByStatus(now, request); - - // Then - assertThat(actual).hasSize(8); - } - - @DisplayName("종료 됐거나 진행 중인 쿠폰들을 조회한다. - List") - @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") - @ParameterizedTest - void couponSearchRepository_findAllByStatus_and_ongoing_ended(List coupons) { - // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, false, true); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); - - couponRepository.saveAll(coupons); - - // When - List actual = couponSearchRepository.findAllByStatus(now, request); - - // Then - assertThat(actual).hasSize(5); + assertThat(actual).hasSize(1); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 7, 1)); } - @DisplayName("진행 중이 아니고, 시작 전이거나, 종료된 쿠폰들을 조회한다. - List") + @DisplayName("모든 쿠폰을 발급 가능 날짜 순으로 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponSearchRepository_findAllByStatus_ongoing_and_ended(List coupons) { + void findAllByStatus_order_by_startAt(List coupons) { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(false, true, true); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); + LocalDate now = LocalDate.now(); couponRepository.saveAll(coupons); @@ -92,16 +59,17 @@ void couponSearchRepository_findAllByStatus_ongoing_and_ended(List coupo List actual = couponSearchRepository.findAllByStatus(now, request); // Then - assertThat(actual).hasSize(7); + assertThat(actual).hasSize(coupons.size()); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 3, 1)); } - @DisplayName("진행 중인 쿠폰을 조회한다. - List") + @DisplayName("발급 가능한 쿠폰 포함하여 쿠폰 정보 오픈 중인 쿠폰들을 발급 가능 날짜 순으로 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponSearchRepository_findAllByStatus_ongoing(List coupons) { + void findAllByStatus_opened_order_by_startAt(List coupons) { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, false, false); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, false); + LocalDate now = LocalDate.of(2023, 7, 1); couponRepository.saveAll(coupons); @@ -110,15 +78,16 @@ void couponSearchRepository_findAllByStatus_ongoing(List coupons) { // Then assertThat(actual).hasSize(3); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 7, 1)); } - @DisplayName("시작 적인 쿠폰들을 조회한다. - List") + @DisplayName("종료된 쿠폰들을 발급 가능 날짜 순으로 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void couponSearchRepository_findAllByStatus_notStarted(List coupons) { + void findAllByStatus_ended_order_by_startAt(List coupons) { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(false, true, false); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, true); + LocalDate now = LocalDate.of(2023, 8, 1); couponRepository.saveAll(coupons); @@ -127,39 +96,6 @@ void couponSearchRepository_findAllByStatus_notStarted(List coupons) { // Then assertThat(actual).hasSize(5); - } - - @DisplayName("종료된 쿠폰들을 조회한다. - List") - @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") - @ParameterizedTest - void couponSearchRepository_findAllByStatus_ended(List coupons) { - // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false, true); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); - - couponRepository.saveAll(coupons); - - // When - List actual = couponSearchRepository.findAllByStatus(now, request); - - // Then - assertThat(actual).hasSize(2); - } - - @DisplayName("상태조건을 걸지 않아서 모든 쿠폰이 조회되지 않는다. - List") - @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") - @ParameterizedTest - void couponSearchRepository_findAllByStatus__not_status(List coupons) { - // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false, false); - LocalDateTime now = LocalDateTime.of(2023, 5, 1, 0, 0); - - couponRepository.saveAll(coupons); - - // When - List actual = couponSearchRepository.findAllByStatus(now, request); - - // Then - assertThat(actual).isEmpty(); + assertThat(actual.get(0).getStartAt()).isEqualTo(LocalDate.of(2023, 3, 1)); } } diff --git a/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java index a3fb6433..5fcf7561 100644 --- a/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java +++ b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java @@ -2,7 +2,7 @@ import static org.assertj.core.api.Assertions.*; -import java.time.LocalDateTime; +import java.time.LocalDate; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -13,19 +13,19 @@ class CreateCouponRequestTest { - @DisplayName("쿠폰 발급 가능 시작 날짜가 올바른 형식으로 입력된다. - yyyy-MM-dd'T'HH:mm") + @DisplayName("쿠폰 발급 가능 시작 날짜가 올바른 형식으로 입력된다. - yyyy-MM-dd") @Test void createCouponRequest_StartAt() throws JsonProcessingException { // Given ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); - String json = "{\"startAt\":\"2023-11-09T10:10\"}"; + String json = "{\"startAt\":\"2023-11-09\"}"; // When CreateCouponRequest actual = objectMapper.readValue(json, CreateCouponRequest.class); // Then - assertThat(actual.startAt()).isEqualTo(LocalDateTime.of(2023, 11, 9, 10, 10)); + assertThat(actual.startAt()).isEqualTo(LocalDate.of(2023, 11, 9)); } } diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index e239880c..87173012 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -63,8 +63,8 @@ class CouponControllerTest extends WithoutFilterSupporter { @Test void create_Coupon() throws Exception { // Given - String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When & Then mockMvc.perform(post("/admins/coupons") @@ -79,12 +79,38 @@ void create_Coupon() throws Exception { } @WithMember(role = Role.ADMIN) - @DisplayName("POST - 쿠폰 발급 종료기간 시작기간보다 이전인 쿠폰을 발행한다. - BadRequestException") + @DisplayName("POST - 현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") + @Test + void create_Coupon_StartAt_BadRequestException() throws Exception { + // Given + CreateCouponRequest request = CouponFixture.createCouponRequest(); + + given(clockHolder.times()).willReturn(LocalDateTime.of(2025, 1, 1, 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()), + CouponSnippetFixture.CREATE_COUPON_REQUEST, + ErrorSnippetFixture.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_BadRequestException() throws Exception { + void create_Coupon_OpenAt_BadRequestException() throws Exception { // Given String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 2, 1); + CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); + + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When & Then mockMvc.perform(post("/admins/coupons") @@ -98,7 +124,7 @@ void create_Coupon_BadRequestException() throws Exception { ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD.getMessage())); } @WithMember(role = Role.ADMIN) @@ -106,8 +132,7 @@ void create_Coupon_BadRequestException() throws Exception { @Test void create_Coupon_ConflictException() throws Exception { // Given - String couponType = CouponType.GOLDEN_COUPON.getName(); - CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 2); + CreateCouponRequest request = CouponFixture.createCouponRequest(); couponRepository.save(CouponMapper.toEntity(1L, request)); // When & Then @@ -195,9 +220,11 @@ void getById_Coupon_NotFoundException() throws Exception { @ParameterizedTest void getAllByStatus_Coupons(List coupons) throws Exception { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true, true); + CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); List coupon = couponRepository.saveAll(coupons); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + // When & Then mockMvc.perform(post("/coupons/search") .contentType(MediaType.APPLICATION_JSON) @@ -213,14 +240,16 @@ void getAllByStatus_Coupons(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(coupon.size()))); } - @DisplayName("POST - 상태 조건을 걸지 않아서 쿠폰이 조회되지 않는다. - List") + @DisplayName("POST - 발급 가능한 쿠폰만 조회한다.. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void getAllByStatus_Coupons_not_status(List coupons) throws Exception { + void getAllByStatus_Coupon(List coupons) throws Exception { // Given - CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false, false); + CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); couponRepository.saveAll(coupons); + given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 3, 1, 1, 1)); + // When & Then mockMvc.perform(post("/coupons/search") .contentType(MediaType.APPLICATION_JSON) @@ -232,7 +261,7 @@ void getAllByStatus_Coupons_not_status(List coupons) throws Exception { CouponSnippetFixture.COUPON_STATUS_REQUEST)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$", hasSize(0))); + .andExpect(jsonPath("$", hasSize(1))); } @WithMember(nickname = "member-coupon-1") @@ -240,11 +269,10 @@ void getAllByStatus_Coupons_not_status(List coupons) throws Exception { @Test void registerQueue() throws Exception { // Given - Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); - LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); + Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(now); + given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 2, 1, 1, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -261,11 +289,10 @@ void registerQueue() throws Exception { @Test void registerQueue_BadRequestException() throws Exception { // Given - Coupon couponFixture = CouponFixture.coupon("CouponName", 1, 2); - LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 1); + Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(now); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 2, 1, 1, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -277,7 +304,7 @@ void registerQueue_BadRequestException() throws Exception { ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD_END.getMessage())); + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } @WithMember @@ -285,10 +312,9 @@ void registerQueue_BadRequestException() throws Exception { @Test void registerQueue_NotFoundException() throws Exception { // Given - Coupon coupon = CouponFixture.coupon("Not found coupon name", 1, 2); - LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 1); + Coupon coupon = CouponFixture.coupon("Not found coupon name", 2, 1); - given(clockHolder.times()).willReturn(now); + given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 2, 1, 1, 1)); // When & Then mockMvc.perform(post("/coupons") diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 09ff443a..af514fb9 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -1,6 +1,6 @@ package com.moabam.support.fixture; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import java.util.stream.Stream; @@ -16,26 +16,38 @@ 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_COUPON) + .stock(100) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") .point(point) .type(CouponType.MORNING_COUPON) .stock(stock) - .startAt(LocalDateTime.of(2023, 1, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, 2, 1, 0, 0)) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) .build(); } - public static Coupon coupon(String name, int startMonth, int endMonth) { + public static Coupon coupon(String name, int startMonth, int openMonth) { return Coupon.builder() .name(name) .point(10) .type(CouponType.MORNING_COUPON) .stock(100) - .startAt(LocalDateTime.of(2023, startMonth, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, endMonth, 1, 0, 0)) + .startAt(LocalDate.of(2023, startMonth, 1)) + .openAt(LocalDate.of(2023, openMonth, 1)) .adminId(1L) .build(); } @@ -46,8 +58,8 @@ public static Coupon discount1000Coupon() { .point(1000) .type(CouponType.DISCOUNT_COUPON) .stock(100) - .startAt(LocalDateTime.of(2023, 1, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, 1, 1, 0, 0)) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) .build(); } @@ -58,28 +70,39 @@ public static Coupon discount10000Coupon() { .point(10000) .type(CouponType.DISCOUNT_COUPON) .stock(100) - .startAt(LocalDateTime.of(2023, 1, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, 1, 1, 0, 0)) + .startAt(LocalDate.of(2023, 2, 1)) + .openAt(LocalDate.of(2023, 2, 1)) .adminId(1L) .build(); } - public static CreateCouponRequest createCouponRequest(String couponType, int startMonth, int endMonth) { + public static CreateCouponRequest createCouponRequest() { + return CreateCouponRequest.builder() + .name("couponName") + .description("coupon description") + .point(10) + .type(CouponType.GOLDEN_COUPON.getName()) + .stock(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) .stock(10) - .startAt(LocalDateTime.of(2023, startMonth, 1, 0, 0)) - .endAt(LocalDateTime.of(2023, endMonth, 1, 0, 0)) + .startAt(LocalDate.of(2023, startMonth, 1)) + .openAt(LocalDate.of(2023, openMonth, 1)) .build(); } - public static CouponStatusRequest couponStatusRequest(boolean ongoing, boolean notStarted, boolean ended) { + public static CouponStatusRequest couponStatusRequest(boolean ongoing, boolean ended) { return CouponStatusRequest.builder() - .ongoing(ongoing) - .notStarted(notStarted) + .opened(ongoing) .ended(ended) .build(); } @@ -87,16 +110,16 @@ public static CouponStatusRequest couponStatusRequest(boolean ongoing, boolean n public static Stream provideCoupons() { return Stream.of(Arguments.of( List.of( - coupon("coupon1", 1, 3), - coupon("coupon2", 2, 4), - coupon("coupon3", 3, 5), - coupon("coupon4", 4, 6), - coupon("coupon5", 5, 7), - coupon("coupon6", 6, 8), - coupon("coupon7", 7, 9), - coupon("coupon8", 8, 10), - coupon("coupon9", 9, 11), - coupon("coupon10", 10, 12) + 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) )) ); } diff --git a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java index 57b7499e..40c7c63a 100644 --- a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java @@ -15,8 +15,8 @@ public final class CouponSnippetFixture { fieldWithPath("type").type(STRING).description("쿠폰 종류 (아침, 저녁, 황금, 할인)"), fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), fieldWithPath("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), - fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), - fieldWithPath("endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + 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( @@ -28,14 +28,13 @@ public final class CouponSnippetFixture { fieldWithPath("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), fieldWithPath("type").type(STRING) .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), - fieldWithPath("startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), - fieldWithPath("endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + 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("ongoing").type(BOOLEAN).description("진행 상태 쿠폰 (true, false)"), - fieldWithPath("notStarted").type(BOOLEAN).description("시작전 상태 쿠폰 (true, false)"), - fieldWithPath("ended").type(BOOLEAN).description("종료 상태 쿠폰 (true, false)") + fieldWithPath("opened").type(BOOLEAN).description("쿠폰 정보가 오픈된 쿠폰 (true, false)"), + fieldWithPath("ended").type(BOOLEAN).description("종료된 쿠폰 (true, false)") ); public static final ResponseFieldsSnippet COUPON_STATUS_RESPONSE = responseFields( @@ -47,7 +46,7 @@ public final class CouponSnippetFixture { fieldWithPath("[].stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), fieldWithPath("[].type").type(STRING) .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), - fieldWithPath("[].startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd'T'HH:mm)"), - fieldWithPath("[].endAt").type(STRING).description("쿠폰 발급 종료 날짜 (Ex: yyyy-MM-dd'T'HH:mm)") + fieldWithPath("[].startAt").type(STRING).description("쿠폰 발급 시작 날짜 (Ex: yyyy-MM-dd)"), + fieldWithPath("[].openAt").type(STRING).description("쿠폰 정보 오픈 날짜 (Ex: yyyy-MM-dd)") ); } From da842267a7e17f7b87b44094bc217bbb231a2b4f Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 23 Nov 2023 11:41:49 +0900 Subject: [PATCH 076/185] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?(#139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 토큰 redis 저장을 위한 dto 및 config 추가 * feat: webConfig 파일 추가 * feat: redis 토큰 저장 서비스 및 테스트 코드 추가 * feat: 에러시 모든 토큰 제거 추가 * refactor: config update * feat: config 추가 * refactor: code smell 제거 * feat: logout 기능 추가 * refactor: 사용자 nickname 생성 및 랜덤 삭제 ID부여 제공 * refacotr: @Transaction제거, redis를 사용하기 때문에 트랜잭션 전파 불필요 * feat: 삭제 요청 추가 * refactor: member mapper 메서드 위치 변경 AuthMapper -> MemberMapper * refacotr: 패키지 위치 변경 및 socialId long->String * feat: 회원탈퇴 요청 기능 추가 * fix: restTemplate 요청 반환 값 변경 * feat: 회원 탈퇴 요청에 대한 api 추가 * test: 회원 삭제 테스트 추가 * test: 회원 탈퇴 테스트 코드 및 Auth테스트와 member테스트 분리 * feat: 회원 탈퇴 서비스 기능 구현 및 restTemplate요청 테스트 추가 * feat: 사용하지 않는 메서드 및 회원 조회 쿼리 생성 * test: 테스트 코드 수정 및 test config 변경 * feat: WebConfig path 수정 * feat: 삭제할 회원 조건 변경 * refacotr: 테스트 로그인 get 메서드 uri변경 및 AuthorizationMember -> AuthMember / CurrentMember -> Auth * refactor: merge develop * fix: findMemberWithNotManager 메서드 명 findMemberNotManager 변경 * refactor: 회원 탈퇴 로직 변경 --- .../auth/AuthorizationService.java | 17 +- .../api/application/member/MemberService.java | 25 +- .../com/moabam/api/domain/member/Member.java | 10 +- .../repository/MemberSearchRepository.java | 11 +- .../api/presentation/MemberController.java | 4 +- src/main/resources/static/docs/coupon.html | 2809 +++++++++++++---- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2641 +++++++++++++--- .../auth/AuthorizationServiceTest.java | 50 +- .../application/member/MemberServiceTest.java | 38 +- .../domain/member/MemberRepositoryTest.java | 18 +- .../presentation/MemberControllerTest.java | 37 +- 12 files changed, 4500 insertions(+), 1162 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index fbd662d9..a13ae804 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -4,6 +4,7 @@ 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; @@ -12,6 +13,7 @@ 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.dto.auth.AuthorizationCodeRequest; import com.moabam.api.dto.auth.AuthorizationCodeResponse; import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; @@ -19,7 +21,6 @@ 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.dto.member.DeleteMemberResponse; import com.moabam.global.auth.model.AuthMember; import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.common.util.GlobalConstant; @@ -119,16 +120,22 @@ public void removeToken(HttpServletRequest httpServletRequest, HttpServletRespon }); } - public void unLinkMember(DeleteMemberResponse deleteMemberResponse) { + @Transactional + public void unLinkMember(AuthMember authMember) { + Member member = memberService.findMemberToDelete(authMember.id()); + unlinkRequest(member.getSocialId()); + memberService.delete(member); + } + + private void unlinkRequest(String socialId) { try { oauth2AuthorizationServerRequestService.unlinkMemberRequest( oAuthConfig.provider().unlink(), oAuthConfig.client().adminKey(), - unlinkRequestParam(deleteMemberResponse.socialId())); - log.info("회원 탈퇴 성공 : [id={}, socialId={}]", deleteMemberResponse.id(), deleteMemberResponse.socialId()); + unlinkRequestParam(socialId)); + log.info("회원 탈퇴 성공 : [socialId={}]", socialId); } catch (BadRequestException badRequestException) { log.warn("회원 탈퇴요청 실패 : 카카오 연결 오류"); - memberService.undoDelete(deleteMemberResponse); throw new BadRequestException(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS); } } diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 0ac73d87..cf6ae849 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -14,9 +14,7 @@ import com.moabam.api.domain.member.repository.MemberSearchRepository; import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; import com.moabam.api.dto.auth.LoginResponse; -import com.moabam.api.dto.member.DeleteMemberResponse; -import com.moabam.global.auth.model.AuthMember; -import com.moabam.global.error.exception.ConflictException; +import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -28,6 +26,7 @@ public class MemberService { private final MemberRepository memberRepository; private final MemberSearchRepository memberSearchRepository; + private final ClockHolder clockHolder; public Member getById(Long memberId) { return memberRepository.findById(memberId) @@ -47,22 +46,16 @@ public List getRoomMembers(List memberIds) { } @Transactional - public DeleteMemberResponse deleteMember(AuthMember authMember) { - Member member = memberSearchRepository.findMemberNotManager(authMember.id()) - .orElseThrow(() -> new ConflictException(MEMBER_NOT_FOUND)); - - String socialId = member.getSocialId(); - member.delete(); - - return MemberMapper.toDeleteMemberResponse(member.getId(), socialId); + public Member findMemberToDelete(Long memberId) { + return memberSearchRepository.findMemberNotManager(memberId) + .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } @Transactional - public void undoDelete(DeleteMemberResponse deleteMemberResponse) { - Member member = memberSearchRepository.findMember(deleteMemberResponse.id(), false) - .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); - - member.undoDelete(deleteMemberResponse.socialId()); + public void delete(Member member) { + member.delete(clockHolder.times()); + memberRepository.flush(); + memberRepository.delete(member); } private Member signUp(Long socialId) { diff --git a/src/main/java/com/moabam/api/domain/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index 56f97c2b..668ea534 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -116,14 +116,8 @@ public void increaseTotalCertifyCount() { this.totalCertifyCount++; } - public void delete() { - deletedAt = LocalDateTime.now(); - socialId = deleteSocialId(deletedAt); - } - - public void undoDelete(String socialId) { - this.socialId = socialId; - deletedAt = null; + public void delete(LocalDateTime now) { + socialId = deleteSocialId(now); } public void changeNickName(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 index b4d15d91..15fd6981 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -1,7 +1,7 @@ package com.moabam.api.domain.member.repository; import static com.moabam.api.domain.member.QMember.*; -import static com.moabam.api.domain.room.QRoom.*; +import static com.moabam.api.domain.room.QParticipant.*; import java.util.Optional; @@ -9,7 +9,6 @@ import com.moabam.api.domain.member.Member; import com.moabam.global.common.util.DynamicQuery; -import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -37,14 +36,10 @@ public Optional findMember(Long memberId, boolean isNotDeleted) { public Optional findMemberNotManager(Long memberId) { return Optional.ofNullable(jpaQueryFactory .selectFrom(member) + .leftJoin(participant).on(member.id.eq(participant.memberId)) .where( member.id.eq(memberId), - JPAExpressions.selectOne() - .from(room) - .where( - member.nickname.eq(room.managerNickname) - ) - .notExists() + participant.isManager.isNull().or(participant.isManager.isFalse()) ) .fetchFirst()); } diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 078b9ec7..704718e0 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -15,7 +15,6 @@ 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.DeleteMemberResponse; import com.moabam.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; @@ -57,7 +56,6 @@ public void logout(@Auth AuthMember authMember, HttpServletRequest httpServletRe @DeleteMapping @ResponseStatus(HttpStatus.OK) public void deleteMember(@Auth AuthMember authMember) { - DeleteMemberResponse deleteMemberResponse = memberService.deleteMember(authMember); - authorizationService.unLinkMember(deleteMemberResponse); + authorizationService.unLinkMember(authMember); } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index df366758..ec72ba0b 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,464 +1,2135 @@ - - - - -쿠폰(Coupon) - - + + + + + 쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 175
@@ -473,62 +2144,62 @@ 

요청

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 201 Created
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
-

쿠폰 삭제

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

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/1 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
-

특정 쿠폰 조회

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

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -547,22 +2218,22 @@ 

응답

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

+
+
POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
 Content-Length: 41
@@ -572,11 +2243,11 @@ 

요청

"opened" : false, "ended" : false }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -595,73 +2266,73 @@ 

응답

"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 400 Bad Request
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 64
+Content-Length: 66
 
 {
   "message" : "쿠폰 발급 가능 기간이 아닙니다."
 }
-
-
-
-
-
-
-

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

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

쿠폰 사용 (진행 중)

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

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

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 62f80fd6..8734736a 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 67060c84..3727fab6 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -1,522 +1,2193 @@ - - - - -알림(Notification) - - + + + + + 알림(Notification) + +
-
-

알림(Notification)

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

콕 찌르기 알림

-
-
+
+

알림(Notification)

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

콕 찌르기 알림

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

요청

-
-
+
+
+

요청

+
+
GET /notifications/rooms/4/members/4 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 64
+Content-Length: 66
 
 {
   "message" : "해당 유저는 접속 중이 아닙니다."
 }
-
-
-
-
-

FCM TOKEN 저장

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

요청

-
-
+
+
+
+
+

FCM TOKEN 저장

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
+
+
+
+
+
- \ No newline at end of file + diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index c339cd12..8824a8c5 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -26,13 +26,13 @@ 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.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.member.DeleteMemberResponse; import com.moabam.global.auth.model.AuthMember; import com.moabam.global.auth.model.PublicClaim; import com.moabam.global.common.util.cookie.CookieDevUtils; @@ -40,12 +40,13 @@ 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.DeleteMemberFixture; +import com.moabam.support.fixture.MemberFixture; import com.moabam.support.fixture.TokenSaveValueFixture; import jakarta.servlet.http.Cookie; @@ -304,30 +305,49 @@ void token_null_delete_fail(@WithMember AuthMember authMember) { @DisplayName("회원 탈퇴 요청 성공") @Test - void unlink_success() { + void unlink_success(@WithMember AuthMember authMember) { // given - DeleteMemberResponse deleteMemberResponse = DeleteMemberFixture.deleteMemberResponse(); + Member member = MemberFixture.member(); + willReturn(member) + .given(memberService) + .findMemberToDelete(authMember.id()); doNothing().when(oAuth2AuthorizationServerRequestService) .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); // When + Then - assertThatNoException().isThrownBy(() -> authorizationService.unLinkMember(deleteMemberResponse)); + assertThatNoException().isThrownBy(() -> authorizationService.unLinkMember(authMember)); } - @DisplayName("연결 요청 실패에 따른 성공 실패") + @DisplayName("회원이 없어서 찾기 실패") @Test - void unlink_fail() { - // given - DeleteMemberResponse deleteMemberResponse = DeleteMemberFixture.deleteMemberResponse(); + void unlink_failBy_find_Member(@WithMember AuthMember authMember) { + // Given + When + willThrow(new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)) + .given(memberService) + .findMemberToDelete(authMember.id()); + + assertThatThrownBy(() -> authorizationService.unLinkMember(authMember)) + .isInstanceOf(NotFoundException.class) + .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); + } - // when - willThrow(BadRequestException.class).given(oAuth2AuthorizationServerRequestService) + @DisplayName("소셜 탈퇴 요청 실패로 인한 실패") + @Test + void unlink_failBy_(@WithMember AuthMember authMember) { + // Given + Member member = MemberFixture.member(); + + willReturn(member) + .given(memberService) + .findMemberToDelete(authMember.id()); + willThrow(BadRequestException.class) + .given(oAuth2AuthorizationServerRequestService) .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); - assertThatThrownBy(() -> authorizationService.unLinkMember(deleteMemberResponse)).isInstanceOf( - BadRequestException.class).hasMessage(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS.getMessage()); - // then - verify(memberService, times(1)).undoDelete(deleteMemberResponse); + // When + Then + assertThatThrownBy(() -> authorizationService.unLinkMember(authMember)) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS.getMessage()); } } diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index f3c5497f..88bc37c7 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; +import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -17,8 +18,8 @@ import com.moabam.api.domain.member.repository.MemberSearchRepository; import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; import com.moabam.api.dto.auth.LoginResponse; -import com.moabam.api.dto.member.DeleteMemberResponse; import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.common.util.ClockHolder; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.FilterProcessExtension; import com.moabam.support.fixture.AuthorizationResponseFixture; @@ -37,6 +38,9 @@ class MemberServiceTest { @Mock MemberSearchRepository memberSearchRepository; + @Mock + ClockHolder clockHolder; + @DisplayName("회원 존재하고 로그인 성공") @Test void member_exist_and_login_success() { @@ -77,40 +81,18 @@ void signUp_success() { assertThat(result.isSignUp()).isTrue(); } - @DisplayName("멤버 삭제 성공") - @Test - void delete_member_test(@WithMember AuthMember authMember) { - // given - Member member = MemberFixture.member(); - String beforeSocialId = member.getSocialId(); - - given(memberSearchRepository.findMemberNotManager(authMember.id())) - .willReturn(Optional.ofNullable(member)); - - // when - DeleteMemberResponse deleteMemberResponse = memberService.deleteMember(authMember); - - // then - assertThat(member).isNotNull(); - assertThat(deleteMemberResponse.socialId()).isEqualTo(beforeSocialId); - assertThat(member.getSocialId()).contains("delete"); - } - - @DisplayName("회원 삭제 반환") + @DisplayName("회원 삭제 성공") @Test void undo_delete_member(@WithMember AuthMember authMember) { // given Member member = MemberFixture.member(); - DeleteMemberResponse deleteMemberResponse = DeleteMemberFixture.deleteMemberResponse(); - - given(memberSearchRepository.findMember(authMember.id(), false)) - .willReturn(Optional.ofNullable(member)); + given(clockHolder.times()).willReturn(LocalDateTime.now()); - // when - memberService.undoDelete(deleteMemberResponse); + // When + memberService.delete(member); // then assertThat(member).isNotNull(); - assertThat(deleteMemberResponse.socialId()).isEqualTo(member.getSocialId()); + assertThat(member.getSocialId()).contains("delete"); } } diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index f9ec3653..72cfe18b 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -11,10 +11,13 @@ 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.support.annotation.QuerydslRepositoryTest; import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.ParticipantFixture; import com.moabam.support.fixture.RoomFixture; @QuerydslRepositoryTest @@ -29,6 +32,9 @@ class MemberRepositoryTest { @Autowired RoomRepository roomRepository; + @Autowired + ParticipantRepository participantRepository; + @DisplayName("회원 생성 테스트") @Test void test() { @@ -51,13 +57,17 @@ class FindMemberTest { @Test void room_exist_and_manager_error() { // given + Member member = MemberFixture.member(); + memberRepository.save(member); + + Optional test1 = memberRepository.findById(1L); + Room room = RoomFixture.room(); - room.changeManagerNickname("nickname"); roomRepository.save(room); - Member member = MemberFixture.member(); - member.changeNickName("nickname"); - memberRepository.save(member); + Participant participant = ParticipantFixture.participant(room, member.getId()); + participant.enableManager(); + participantRepository.save(participant); // when Optional memberOptional = diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 0076ed42..ef769faf 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -39,7 +39,7 @@ 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.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.global.config.OAuthConfig; @@ -48,7 +48,6 @@ 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.fixture.TokenSaveValueFixture; @Transactional @@ -76,6 +75,9 @@ class MemberControllerTest extends WithoutFilterSupporter { @Autowired RoomRepository roomRepository; + @Autowired + ParticipantRepository participantRepository; + @Autowired OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; @@ -126,6 +128,8 @@ void logout_success() throws Exception { @WithMember @Test void delete_member_success() throws Exception { + // Given + String nickname = member.getNickname(); // expected mockRestServiceServer.expect(requestTo(oAuthConfig.provider().unlink())) @@ -136,16 +140,24 @@ void delete_member_success() throws Exception { .andExpect(method(HttpMethod.POST)) .andRespond(withStatus(HttpStatus.OK)); - ResultActions result = mockMvc.perform(delete("/members")); + 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()).isEqualTo(nickname); } @DisplayName("회원이 없어서 회원 삭제 실패") @WithMember(id = 123L) @Test - void delete_member_failby_not_found_member() throws Exception { + void delete_member_failBy_not_found_member() throws Exception { // expected mockMvc.perform(delete("/members")) - .andExpect(status().isConflict()); + .andExpect(status().isNotFound()); } @DisplayName("연결 오류로 인한 카카오 연결 끊기 실패로 롤백") @@ -174,19 +186,4 @@ void unlink_social_member_failby_connection_error_and_rollback(int code) throws () -> 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()); - - roomRepository.save(room); - - // then - ResultActions result = mockMvc.perform(delete("/members")) - .andExpect(status().isConflict()); - } } From 0d29e0ab1a1c8c89ac98aecae4fc513c5932d15a Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 23 Nov 2023 14:40:03 +0900 Subject: [PATCH 077/185] =?UTF-8?q?feat:=20=EB=B2=8C=EB=A0=88=20=EC=83=81?= =?UTF-8?q?=ED=92=88=20=EA=B5=AC=EB=A7=A4=20=EC=8B=9C=20CouponWallet=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 벌레 상품 구매 시 couponWallet 검증 로직 적용 * fix: couponWalletId를 받도록 수정 * test: couponWallet 적용 테스트 * chore: 불필요한 fixture 제거 * fix: 보유한 쿠폰 조회 시 fetch join 적용 * test: 쿠폰 지갑 레포지토리 테스트 * chore: 사용하지 않는 메서드 제거 --- .../api/application/bug/BugService.java | 12 ++- .../api/application/coupon/CouponService.java | 8 ++ .../CouponWalletSearchRepository.java | 31 ++++++++ .../moabam/api/domain/payment/Payment.java | 15 ++-- .../api/domain/payment/PaymentStatus.java | 17 ++++- .../dto/product/PurchaseProductRequest.java | 2 +- .../api/presentation/BugController.java | 3 +- .../global/error/model/ErrorMessage.java | 2 + .../api/application/bug/BugServiceTest.java | 33 +++++++++ .../CouponWalletSearchRepositoryTest.java | 41 +++++++++++ .../api/domain/payment/PaymentTest.java | 9 ++- .../api/presentation/BugControllerTest.java | 73 +++++-------------- .../presentation/PaymentControllerTest.java | 2 - .../support/config/TestQuerydslConfig.java | 6 ++ .../moabam/support/fixture/CouponFixture.java | 8 ++ 15 files changed, 183 insertions(+), 79 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java create mode 100644 src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index dcb87c13..de07033f 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -9,11 +9,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.application.coupon.CouponService; 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.coupon.Coupon; -import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.member.Member; import com.moabam.api.domain.payment.Payment; import com.moabam.api.domain.payment.repository.PaymentRepository; @@ -33,9 +33,9 @@ public class BugService { private final MemberService memberService; + private final CouponService couponService; private final ProductRepository productRepository; private final PaymentRepository paymentRepository; - private final CouponRepository couponRepository; public BugResponse getBug(Long memberId) { Member member = memberService.getById(memberId); @@ -54,11 +54,9 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, Product product = getById(productId); Payment payment = PaymentMapper.toPayment(memberId, product); - if (!isNull(request.couponId())) { - // TODO: (임시) CouponWallet 에 존재하는 할인 쿠폰인지 확인 @홍혁준 - Coupon coupon = couponRepository.findById(request.couponId()) - .orElseThrow(() -> new NotFoundException(NOT_FOUND_COUPON)); - payment.applyCoupon(coupon); + if (!isNull(request.couponWalletId())) { + Coupon coupon = couponService.getByWallet(request.couponWalletId(), memberId); + payment.applyCoupon(coupon, request.couponWalletId()); } paymentRepository.save(payment); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 0d080df4..cdc6fa44 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -9,6 +9,7 @@ import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.coupon.repository.CouponSearchRepository; +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; @@ -29,6 +30,7 @@ public class CouponService { private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; + private final CouponWalletSearchRepository couponWalletSearchRepository; private final ClockHolder clockHolder; @Transactional @@ -56,6 +58,12 @@ public CouponResponse getById(Long couponId) { return CouponMapper.toDto(coupon); } + public Coupon getByWallet(Long couponWalletId, Long memberId) { + return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)) + .getCoupon(); + } + public List getAllByStatus(CouponStatusRequest request) { LocalDate now = LocalDate.from(clockHolder.times()); List coupons = couponSearchRepository.findAllByStatus(now, request); 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..abd93f5c --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java @@ -0,0 +1,31 @@ +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.Optional; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.domain.coupon.CouponWallet; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponWalletSearchRepository { + + private final JPAQueryFactory jpaQueryFactory; + + public Optional findByIdAndMemberId(Long id, Long memberId) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(couponWallet) + .join(couponWallet.coupon, coupon).fetchJoin() + .where( + couponWallet.id.eq(id), + couponWallet.memberId.eq(memberId)) + .fetchOne() + ); + } +} diff --git a/src/main/java/com/moabam/api/domain/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java index 4f549cf4..cf55b607 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -23,6 +23,7 @@ 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; @@ -33,7 +34,7 @@ @Entity @Getter -@Table(name = "payment") +@Table(name = "payment", indexes = @Index(name = "idx_order_id", columnList = "order_id")) @NoArgsConstructor(access = AccessLevel.PROTECTED) @EntityListeners(AuditingEntityListener.class) public class Payment { @@ -56,6 +57,9 @@ public class Payment { @JoinColumn(name = "coupon_id") private Coupon coupon; + @Column(name = "coupon_wallet_id") + private Long couponWalletId; + @Embedded private Order order; @@ -85,7 +89,7 @@ public Payment(Long memberId, Product product, Order order, int amount, PaymentS this.product = requireNonNull(product); this.order = requireNonNull(order); this.amount = validateAmount(amount); - this.status = requireNonNullElse(status, PaymentStatus.PENDING); + this.status = requireNonNullElse(status, PaymentStatus.READY); } private int validateAmount(int amount) { @@ -102,13 +106,14 @@ public void validateByMember(Long memberId) { } } - public void applyCoupon(Coupon coupon) { + public void applyCoupon(Coupon coupon, Long couponWalletId) { this.coupon = coupon; - this.amount = max(MIN_AMOUNT, amount - coupon.getPoint()); + this.couponWalletId = couponWalletId; + this.amount = max(MIN_AMOUNT, this.amount - coupon.getPoint()); } public void request(String orderId) { this.order.updateId(orderId); - this.status = PaymentStatus.REQUEST; + this.requestedAt = LocalDateTime.now(); } } diff --git a/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java index 1c567b22..dae76ff6 100644 --- a/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java +++ b/src/main/java/com/moabam/api/domain/payment/PaymentStatus.java @@ -1,10 +1,19 @@ package com.moabam.api.domain.payment; +/** + * READY: 결제 생성 + * IN_PROGRESS: 결제 인증 완료 + * DONE: 결제 승인 완료 + * CANCELED: 승인된 결제 취소 + * ABORTED: 결제 승인 실패 + * EXPIRED: 유효 시간 경과로 거래 취소 + */ public enum PaymentStatus { - PENDING, - REQUEST, + READY, + IN_PROGRESS, DONE, - FAIL, - REFUND; + CANCELED, + ABORTED, + EXPIRED; } diff --git a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java index 394bbeba..a14a00c7 100644 --- a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java @@ -3,7 +3,7 @@ import javax.annotation.Nullable; public record PurchaseProductRequest( - @Nullable Long couponId + @Nullable Long couponWalletId ) { } diff --git a/src/main/java/com/moabam/api/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 5548a543..41a56009 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -41,8 +41,7 @@ public ProductsResponse getBugProducts() { @PostMapping("/products/{productId}/purchase") @ResponseStatus(HttpStatus.OK) - public PurchaseProductResponse purchaseBugProduct(@Auth AuthMember member, - @PathVariable Long productId, + 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/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index a0fdf88e..c4971757 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -55,6 +55,7 @@ public enum ErrorMessage { PAYMENT_NOT_FOUND("존재하지 않는 결제 정보입니다."), INVALID_MEMBER_PAYMENT("해당 회원의 결제 정보가 아닙니다."), + INVALID_PAYMENT_INFO("결제 정보가 일치하지 않습니다."), FAILED_FCM_INIT("파이어베이스 설정을 실패했습니다."), NOT_FOUND_FCM_TOKEN("해당 유저는 접속 중이 아닙니다."), @@ -69,6 +70,7 @@ public enum ErrorMessage { CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), + NOT_FOUND_COUPON_WALLET("보유하지 않은 쿠폰입니다."), S3_UPLOAD_FAIL("S3 업로드를 실패했습니다."), S3_INVALID_IMAGE("올바른 이미지(파일) 형식이 아닙니다."), diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index 06e9a1c3..0d4bce1f 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -1,6 +1,7 @@ package com.moabam.api.application.bug; import static com.moabam.api.domain.product.ProductType.*; +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.*; @@ -17,15 +18,20 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.application.coupon.CouponService; 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.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; @@ -38,9 +44,15 @@ class BugServiceTest { @Mock MemberService memberService; + @Mock + CouponService couponService; + @Mock ProductRepository productRepository; + @Mock + PaymentRepository paymentRepository; + @DisplayName("벌레를 조회한다.") @Test void get_bug_success() { @@ -80,6 +92,27 @@ void get_bug_products_success() { @Nested class PurchaseBugProduct { + @DisplayName("쿠폰 적용에 성공한다.") + @Test + void apply_coupon_success() { + // given + Long memberId = 1L; + Long productId = 1L; + Long couponWalletId = 1L; + 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(couponService.getByWallet(couponWalletId, memberId)).willReturn(discount1000Coupon()); + + // 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() { 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..f1066346 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -0,0 +1,41 @@ +package com.moabam.api.domain.coupon.repository; + +import static com.moabam.support.fixture.CouponFixture.*; +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; +import com.moabam.support.annotation.QuerydslRepositoryTest; + +@QuerydslRepositoryTest +class CouponWalletSearchRepositoryTest { + + @Autowired + private CouponRepository couponRepository; + + @Autowired + private CouponWalletRepository couponWalletRepository; + + @Autowired + private CouponWalletSearchRepository couponWalletSearchRepository; + + @DisplayName("회원의 특정 쿠폰 지갑을 조회한다.") + @Test + void find_by_id_and_member_id() { + // given + Long id = 1L; + Long memberId = 1L; + Coupon coupon = couponRepository.save(discount1000Coupon()); + couponWalletRepository.save(couponWallet(memberId, coupon)); + + // when + CouponWallet actual = couponWalletSearchRepository.findByIdAndMemberId(id, memberId).orElseThrow(); + + // then + assertThat(actual.getCoupon()).isEqualTo(coupon); + } +} diff --git a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java index 190ce1c2..8a2dfffb 100644 --- a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -38,13 +38,15 @@ void success() { // given Payment payment = payment(bugProduct()); Coupon coupon = discount1000Coupon(); + Long couponWalletId = 1L; // when - payment.applyCoupon(coupon); + payment.applyCoupon(coupon, couponWalletId); // then assertThat(payment.getAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); assertThat(payment.getCoupon()).isEqualTo(coupon); + assertThat(payment.getCouponWalletId()).isEqualTo(couponWalletId); } @DisplayName("할인 금액이 더 크면 0으로 처리한다.") @@ -53,13 +55,13 @@ void discount_amount_greater() { // given Payment payment = payment(bugProduct()); Coupon coupon = discount10000Coupon(); + Long couponWalletId = 1L; // when - payment.applyCoupon(coupon); + payment.applyCoupon(coupon, couponWalletId); // then assertThat(payment.getAmount()).isZero(); - assertThat(payment.getCoupon()).isEqualTo(coupon); } } @@ -87,6 +89,5 @@ void request_success() { // then assertThat(payment.getOrder().getId()).isEqualTo(ORDER_ID); - assertThat(payment.getStatus()).isEqualTo(PaymentStatus.REQUEST); } } diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 7253b637..34025882 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -2,7 +2,6 @@ import static com.moabam.global.auth.model.AuthorizationThreadLocal.*; 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 java.nio.charset.StandardCharsets.*; @@ -16,7 +15,6 @@ 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; @@ -31,8 +29,6 @@ import com.moabam.api.application.product.ProductMapper; import com.moabam.api.domain.bug.repository.BugHistoryRepository; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; -import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.coupon.repository.CouponRepository; import com.moabam.api.domain.product.Product; import com.moabam.api.domain.product.repository.ProductRepository; import com.moabam.api.dto.bug.BugResponse; @@ -65,9 +61,6 @@ class BugControllerTest extends WithoutFilterSupporter { @Autowired ProductRepository productRepository; - @Autowired - CouponRepository couponRepository; - @DisplayName("벌레를 조회한다.") @WithMember @Test @@ -108,53 +101,25 @@ void get_bug_products_success() throws Exception { assertThat(actual).isEqualTo(expected); } - @Nested @DisplayName("벌레 상품을 구매한다.") - class PurchaseBugProduct { - - @DisplayName("성공한다.") - @WithMember - @Test - void 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); - } - - @DisplayName("쿠폰을 적용하여 성공한다.") - @WithMember - @Test - void with_coupon_success() throws Exception { - // given - Product product = productRepository.save(bugProduct()); - Coupon coupon = couponRepository.save(discount1000Coupon()); - PurchaseProductRequest request = new PurchaseProductRequest(coupon.getId()); - - // 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 - 1000); - } + @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/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index 9e9ad410..536b4ab4 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -21,7 +21,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; 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.product.Product; import com.moabam.api.domain.product.repository.ProductRepository; @@ -67,7 +66,6 @@ void success() throws Exception { .andDo(print()); Payment actual = paymentRepository.findById(payment.getId()).orElseThrow(); assertThat(actual.getOrder().getId()).isEqualTo(ORDER_ID); - assertThat(actual.getStatus()).isEqualTo(PaymentStatus.REQUEST); } @DisplayName("결제 요청 바디가 유효하지 않으면 예외가 발생한다.") diff --git a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java index cc44b83f..c494ab42 100644 --- a/src/test/java/com/moabam/support/config/TestQuerydslConfig.java +++ b/src/test/java/com/moabam/support/config/TestQuerydslConfig.java @@ -4,6 +4,7 @@ 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; @@ -44,4 +45,9 @@ public CertificationsSearchRepository certificationsSearchRepository() { 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/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index af514fb9..9d02a077 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -8,6 +8,7 @@ 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.CouponStatusRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; @@ -76,6 +77,13 @@ public static Coupon discount10000Coupon() { .build(); } + public static CouponWallet couponWallet(Long memberId, Coupon coupon) { + return CouponWallet.builder() + .memberId(memberId) + .coupon(coupon) + .build(); + } + public static CreateCouponRequest createCouponRequest() { return CreateCouponRequest.builder() .name("couponName") From 6876104dbeed9098167e268ea7916b9da5d0eda2 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 23 Nov 2023 19:32:45 +0900 Subject: [PATCH 078/185] =?UTF-8?q?feature:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 새 스킨 조회 기능 및 테스트 코드 추가 * chore: jpa관련 config 설정 - 버전 호환오류로 인한 기본 Template설정 * feat: 기본 새 스킨 조회 query 추가 * feat: 회원과 벌레에 대한 조회 쿼리 및 테스트 코드 추가 * feat: 회원 정보 조회 기능 및 테스트 코드 추가 * refactor: 회원과 Item 서비스의 의존성 순환을 피하기 위해 inventorySearchService 생성 * refactor: 회원과 Item 서비스의 의존성 순환을 피하기 위해 inventorySearchService 생성 * feat: 회원 정보 조회 API 추가 * style: 메서드 접근 제어자에 따른 순서 변경 * refactor: inventorySearchService 제거 후 memberService에서 repository 추가 * refactor: transform에서 stream으로 동작 변경 * style: 리뷰 반영 --- .../api/application/member/MemberMapper.java | 66 +- .../api/application/member/MemberService.java | 47 + .../repository/InventorySearchRepository.java | 10 + .../com/moabam/api/domain/member/Badge.java | 48 + .../moabam/api/domain/member/BadgeType.java | 35 + .../member/repository/BadgeRepository.java | 9 + .../repository/MemberSearchRepository.java | 31 + .../moabam/api/dto/member/BadgeResponse.java | 13 + .../com/moabam/api/dto/member/MemberInfo.java | 20 + .../api/dto/member/MemberInfoResponse.java | 26 + .../dto/member/MemberInfoSearchResponse.java | 21 + .../api/presentation/MemberController.java | 7 + .../global/common/util/GlobalConstant.java | 1 + .../com/moabam/global/config/JpaConfig.java | 1 + .../global/error/model/ErrorMessage.java | 2 + src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2815 ++++------------- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2639 +++------------ .../application/member/MemberServiceTest.java | 131 +- .../InventorySearchRepositoryTest.java | 37 + .../domain/member/MemberRepositoryTest.java | 69 +- .../presentation/MemberControllerTest.java | 233 +- .../moabam/support/fixture/BadgeFixture.java | 14 + .../fixture/MemberInfoSearchFixture.java | 36 + 25 files changed, 1906 insertions(+), 4409 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/member/Badge.java create mode 100644 src/main/java/com/moabam/api/domain/member/BadgeType.java create mode 100644 src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java create mode 100644 src/main/java/com/moabam/api/dto/member/BadgeResponse.java create mode 100644 src/main/java/com/moabam/api/dto/member/MemberInfo.java create mode 100644 src/main/java/com/moabam/api/dto/member/MemberInfoResponse.java create mode 100644 src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java create mode 100644 src/test/java/com/moabam/support/fixture/BadgeFixture.java create mode 100644 src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java diff --git a/src/main/java/com/moabam/api/application/member/MemberMapper.java b/src/main/java/com/moabam/api/application/member/MemberMapper.java index 6bdd4e64..3c823e22 100644 --- a/src/main/java/com/moabam/api/application/member/MemberMapper.java +++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java @@ -1,8 +1,22 @@ package com.moabam.api.application.member; +import static com.moabam.global.common.util.GlobalConstant.*; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.item.Inventory; +import com.moabam.api.domain.member.BadgeType; import com.moabam.api.domain.member.Member; -import com.moabam.api.dto.member.DeleteMemberResponse; +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 lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -17,10 +31,52 @@ public static Member toMember(Long socialId) { .build(); } - public static DeleteMemberResponse toDeleteMemberResponse(Long memberId, String socialId) { - return DeleteMemberResponse.builder() - .socialId(socialId) - .id(memberId) + 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()) + .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, + List inventories) { + 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(inventories)) + .badges(badgedNames(memberInfoSearchResponse.badges())) + .goldenBug(memberInfoSearchResponse.goldenBug()) + .morningBug(memberInfoSearchResponse.morningBug()) + .nightBug(memberInfoSearchResponse.nightBug()) + .build(); + } + + private static List badgedNames(Set badgeTypes) { + return BadgeType.memberBadgeMap(badgeTypes); + } + + private static Map defaultSkins(List inventories) { + return inventories.stream() + .collect(Collectors.toMap( + inventory -> inventory.getItem().getType().name(), + inventory -> inventory.getItem().getImage() + )); + } } diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index cf6ae849..01b3201e 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -3,18 +3,27 @@ import static com.moabam.global.error.model.ErrorMessage.*; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import com.moabam.api.application.auth.mapper.AuthMapper; +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.member.repository.MemberRepository; import com.moabam.api.domain.member.repository.MemberSearchRepository; 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.global.auth.model.AuthMember; import com.moabam.global.common.util.ClockHolder; +import com.moabam.global.common.util.GlobalConstant; +import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -25,6 +34,7 @@ public class MemberService { private final MemberRepository memberRepository; + private final InventorySearchRepository inventorySearchRepository; private final MemberSearchRepository memberSearchRepository; private final ClockHolder clockHolder; @@ -58,9 +68,46 @@ public void delete(Member member) { memberRepository.delete(member); } + 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); + List inventories = getDefaultSkin(searchId); + + return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse, inventories); + } + + private List getDefaultSkin(Long searchId) { + List inventories = inventorySearchRepository.findBirdsDefaultSkin(searchId); + if (inventories.size() != GlobalConstant.DEFAULT_SKIN_SIZE) { + throw new BadRequestException(INVALID_DEFAULT_SKIN_SIZE); + } + + return inventories; + } + private Member signUp(Long socialId) { Member member = MemberMapper.toMember(socialId); return memberRepository.save(member); } + + 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/domain/item/repository/InventorySearchRepository.java b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java index 2ab6e605..c4f85cb5 100644 --- a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java @@ -53,4 +53,14 @@ public List findItems(Long memberId, ItemType type) { .select(item) .fetch(); } + + public List findBirdsDefaultSkin(Long searchId) { + return jpaQueryFactory.selectFrom(inventory) + .join(inventory.item) + .on(inventory.item.id.eq(item.id)) + .where( + inventory.memberId.eq(searchId), + inventory.isDefault.isTrue() + ).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..92e90edf --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/BadgeType.java @@ -0,0 +1,35 @@ +package com.moabam.api.domain.member; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; + +import com.moabam.api.dto.member.BadgeResponse; + +import lombok.Getter; + +@Getter +public enum BadgeType { + + MORNING_BIRTH("MORNING", "오목눈이 탄생"), + MORNING_ADULT("MORNING", "어른 오목눈이"), + NIGHT_BIRTH("NIGHT", "부엉이 탄생"), + NIGHT_ADULT("NIGHT", "어른 부엉이"); + + private final String period; + private final String korean; + + BadgeType(String period, String korean) { + this.period = period; + this.korean = korean; + } + + public static List memberBadgeMap(Set badgeTypes) { + return Arrays.stream(BadgeType.values()) + .map(badgeType -> BadgeResponse.builder() + .badge(badgeType) + .unlock(badgeTypes.contains(badgeType)) + .build()) + .toList(); + } +} 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..dd16ebff --- /dev/null +++ b/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java @@ -0,0 +1,9 @@ +package com.moabam.api.domain.member.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.moabam.api.domain.member.Badge; + +public interface BadgeRepository extends JpaRepository { + +} 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 index 15fd6981..a90e0009 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -1,14 +1,20 @@ 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.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; @@ -43,4 +49,29 @@ public Optional findMemberNotManager(Long memberId) { ) .fetchFirst()); } + + public List findMemberAndBadges(Long searchId, boolean isMe) { + List> selectExpression = new ArrayList<>(List.of( + member.nickname, + member.profileImage, + 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/dto/member/BadgeResponse.java b/src/main/java/com/moabam/api/dto/member/BadgeResponse.java new file mode 100644 index 00000000..7e83d5d6 --- /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( + BadgeType badge, + boolean unlock +) { + +} 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..189469c4 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/MemberInfo.java @@ -0,0 +1,20 @@ +package com.moabam.api.dto.member; + +import com.moabam.api.domain.member.BadgeType; + +public record MemberInfo( + String nickname, + String profileImage, + String intro, + long totalCertifyCount, + BadgeType badges, + Integer goldenBug, + Integer morningBug, + Integer nightBug +) { + + public MemberInfo(String nickname, String profileImage, String intro, + long totalCertifyCount, BadgeType badges) { + this(nickname, profileImage, 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..e22070cc --- /dev/null +++ b/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java @@ -0,0 +1,21 @@ +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 intro, + long totalCertifyCount, + Set badges, + Integer goldenBug, + Integer morningBug, + Integer nightBug +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 704718e0..5c5da9a0 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -3,6 +3,7 @@ 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; @@ -15,6 +16,7 @@ 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.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; @@ -58,4 +60,9 @@ public void logout(@Auth AuthMember authMember, HttpServletRequest httpServletRe 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); + } } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 5b3d4dae..19e2a26d 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -17,4 +17,5 @@ public class GlobalConstant { public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; + public static final int DEFAULT_SKIN_SIZE = 2; } diff --git a/src/main/java/com/moabam/global/config/JpaConfig.java b/src/main/java/com/moabam/global/config/JpaConfig.java index 9f0b6906..a443a76e 100644 --- a/src/main/java/com/moabam/global/config/JpaConfig.java +++ b/src/main/java/com/moabam/global/config/JpaConfig.java @@ -4,6 +4,7 @@ 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; diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index c4971757..ddc96335 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -36,6 +36,8 @@ public enum ErrorMessage { MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS("카카오 연결 요청 실패로 Rollback하였습니다."), + INVALID_DEFAULT_SKIN_SIZE("기본 스킨은 2개여야 합니다. 관리자에게 문의하세요"), + BUG_NOT_ENOUGH("보유한 벌레가 부족합니다."), ITEM_NOT_FOUND("존재하지 않는 아이템입니다."), diff --git a/src/main/resources/config b/src/main/resources/config index 35c04d25..2e460460 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 35c04d25c466b163ffceaf81b5d7e8855b78d7ec +Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index ec72ba0b..d31fe33a 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,2138 +1,467 @@ - - - - - 쿠폰(Coupon) - - + + + + +쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 175
+Content-Length: 183
 Host: localhost:8080
 
 {
@@ -2144,68 +473,68 @@ 

요청

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 201 Created
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
-

쿠폰 삭제

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

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/1 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
-

특정 쿠폰 조회

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

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 205
+Content-Length: 215
 
 {
   "id" : 26,
@@ -2218,42 +547,42 @@ 

응답

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
 Content-Type: application/json
-Content-Length: 206
+Content-Length: 216
 
 [ {
   "id" : 15,
@@ -2266,33 +595,33 @@ 

응답

"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 400 Bad Request
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -2303,36 +632,36 @@ 

응답

{ "message" : "쿠폰 발급 가능 기간이 아닙니다." }
-
-
-
-
-
-
-

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

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

쿠폰 사용 (진행 중)

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

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

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

쿠폰 사용 (진행 중)

+
+
+
사용자가 자신의 보관함에 있는 쿠폰들을 사용합니다.
+
+
+
+
+
- + \ No newline at end of file diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 8734736a..9996cde1 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 3727fab6..38ed7f63 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -1,2144 +1,473 @@ - - - - - 알림(Notification) - - + + + + +알림(Notification) + +
-
-

알림(Notification)

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

콕 찌르기 알림

-
-
+
+

알림(Notification)

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

콕 찌르기 알림

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

요청

-
-
+
+
+

요청

+
+
GET /notifications/rooms/4/members/4 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 404 Not Found
 Vary: Origin
 Vary: Access-Control-Request-Method
@@ -2149,45 +478,45 @@ 

응답

{ "message" : "해당 유저는 접속 중이 아닙니다." }
-
-
-
-
-

FCM TOKEN 저장

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

요청

-
-
+
+
+
+
+

FCM TOKEN 저장

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Vary: Origin
 Vary: Access-Control-Request-Method
 Vary: Access-Control-Request-Headers
-
-
-
-
-
+
+
+
+
+
- + \ No newline at end of file diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index 88bc37c7..8db4966b 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -1,30 +1,42 @@ package com.moabam.api.application.member; +import static com.moabam.global.error.model.ErrorMessage.*; 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.domain.item.Inventory; +import com.moabam.api.domain.item.Item; +import com.moabam.api.domain.item.repository.InventorySearchRepository; 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.dto.auth.AuthorizationTokenInfoResponse; import com.moabam.api.dto.auth.LoginResponse; +import com.moabam.api.dto.member.MemberInfoResponse; 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.DeleteMemberFixture; +import com.moabam.support.fixture.InventoryFixture; +import com.moabam.support.fixture.ItemFixture; import com.moabam.support.fixture.MemberFixture; +import com.moabam.support.fixture.MemberInfoSearchFixture; @ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class MemberServiceTest { @@ -38,6 +50,9 @@ class MemberServiceTest { @Mock MemberSearchRepository memberSearchRepository; + @Mock + InventorySearchRepository inventorySearchRepository; + @Mock ClockHolder clockHolder; @@ -95,4 +110,118 @@ void undo_delete_member(@WithMember AuthMember authMember) { 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)); + given(inventorySearchRepository.findBirdsDefaultSkin(authMember.id())) + .willReturn(List.of( + InventoryFixture.inventory(authMember.id(), morning), + InventoryFixture.inventory(authMember.id(), night))); + + // 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); + + given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) + .willReturn(MemberInfoSearchFixture.myInfo()); + given(inventorySearchRepository.findBirdsDefaultSkin(searchId)).willReturn(List.of(morningSkin, nightSkin)); + + // when + MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); + + // then + assertThat(memberInfoResponse.birds()).containsEntry("MORNING", morningSkin.getItem().getImage()); + assertThat(memberInfoResponse.birds()).containsEntry("NIGHT", nightSkin.getItem().getImage()); + } + + @DisplayName("기본 스킨이 없어서 예외 발생") + @Test + void failBy_underSize(@WithMember AuthMember authMember) { + // given + given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) + .willReturn(MemberInfoSearchFixture.friendMemberInfo()); + given(inventorySearchRepository.findBirdsDefaultSkin(anyLong())).willReturn(List.of()); + + // when + assertThatThrownBy(() -> memberService.searchInfo(authMember, 123L)) + .isInstanceOf(BadRequestException.class) + .hasMessage(INVALID_DEFAULT_SKIN_SIZE.getMessage()); + } + + @DisplayName("기본 스킨이 3개 이상이어서 예외 발생") + @Test + void failBy_overSize(@WithMember AuthMember authMember) { + // given + long searchId = 1L; + Item morning = ItemFixture.morningSantaSkin().build(); + Item night = ItemFixture.nightMageSkin(); + Item kill = ItemFixture.morningKillerSkin().build(); + Inventory morningSkin = InventoryFixture.inventory(searchId, morning); + Inventory nightSkin = InventoryFixture.inventory(searchId, night); + Inventory killSkin = InventoryFixture.inventory(searchId, kill); + + given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) + .willReturn(MemberInfoSearchFixture.myInfo()); + given(inventorySearchRepository.findBirdsDefaultSkin(searchId)) + .willReturn(List.of(morningSkin, nightSkin, killSkin)); + + // when + assertThatThrownBy(() -> memberService.searchInfo(authMember, null)) + .isInstanceOf(BadRequestException.class) + .hasMessage(INVALID_DEFAULT_SKIN_SIZE.getMessage()); + } + } } 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 index 58f2a8df..cfeddcea 100644 --- a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -19,6 +19,9 @@ import com.moabam.api.domain.member.Member; import com.moabam.api.domain.member.repository.MemberRepository; 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 { @@ -107,4 +110,38 @@ void find_default_success() { // then assertThat(actual).isPresent().contains(inventory); } + + @DisplayName("기본 새 찾는 쿼리") + @Nested + class FindDefaultBird { + + @DisplayName("default 가져오기 성공") + @Test + void bird_find_success() { + // given + Member member = MemberFixture.member(); + member.enterMorningRoom(); + 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(); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // when + List inventories = inventorySearchRepository.findBirdsDefaultSkin(member.getId()); + + // then + assertThat(inventories).hasSize(2); + } + } } diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index 72cfe18b..071605d1 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.*; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -9,13 +10,18 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +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.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.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 com.moabam.support.fixture.ParticipantFixture; import com.moabam.support.fixture.RoomFixture; @@ -32,6 +38,9 @@ class MemberRepositoryTest { @Autowired RoomRepository roomRepository; + @Autowired + BadgeRepository badgeRepository; + @Autowired ParticipantRepository participantRepository; @@ -60,8 +69,6 @@ void room_exist_and_manager_error() { Member member = MemberFixture.member(); memberRepository.save(member); - Optional test1 = memberRepository.findById(1L); - Room room = RoomFixture.room(); roomRepository.save(room); @@ -97,4 +104,62 @@ void room_exist_and_not_manager_success() { assertThat(memberOptional).isNotEmpty(); } } + + @DisplayName("회원 정보 찾는 Query") + @Nested + class FindMemberInfo { + + @DisplayName("회원 없어서 실패") + @Test + void member_not_found() { + // Given + List memberInfos = memberSearchRepository.findMemberAndBadges(1L, false); + + // When + Then + assertThat(memberInfos).isEmpty(); + } + + @DisplayName("성공") + @Test + void search_info_success() { + // given + Member member = MemberFixture.member(); + member.enterMorningRoom(); + memberRepository.save(member); + + Badge morningBirth = BadgeFixture.badge(member.getId(), BadgeType.MORNING_BIRTH); + Badge morningAdult = BadgeFixture.badge(member.getId(), BadgeType.MORNING_ADULT); + Badge nightBirth = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_BIRTH); + Badge nightAdult = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_ADULT); + List badges = List.of(morningBirth, morningAdult, nightBirth, nightAdult); + 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(); + member.enterMorningRoom(); + memberRepository.save(member); + + // when + List memberInfos = memberSearchRepository.findMemberAndBadges(member.getId(), true); + + // then + assertThat(memberInfos).isNotEmpty(); + + MemberInfoSearchResponse memberInfoSearchResponse = MemberMapper.toMemberInfoSearchResponse(memberInfos); + assertThat(memberInfoSearchResponse.badges()).isEmpty(); + } + } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index ef769faf..8fc82662 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -1,12 +1,15 @@ 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.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.util.List; import java.util.Optional; import org.assertj.core.api.Assertions; @@ -30,15 +33,25 @@ 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.domain.auth.repository.TokenRepository; +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.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; @@ -47,7 +60,12 @@ 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; @Transactional @@ -75,6 +93,15 @@ class MemberControllerTest extends WithoutFilterSupporter { @Autowired RoomRepository roomRepository; + @Autowired + ItemRepository itemRepository; + + @Autowired + BadgeRepository badgeRepository; + + @Autowired + InventoryRepository inventoryRepository; + @Autowired ParticipantRepository participantRepository; @@ -95,7 +122,8 @@ void allSetUp() { restTemplateBuilder = new RestTemplateBuilder() .errorHandler(new RestTemplateResponseHandler()); - member = MemberFixture.member(); + member = MemberFixture.member("1", "nickname"); + member.increaseTotalCertifyCount(); memberRepository.save(member); } @@ -186,4 +214,207 @@ void unlink_social_member_failby_connection_error_and_rollback(int code) throws () -> 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 morningBirth = BadgeFixture.badge(member.getId(), BadgeType.MORNING_BIRTH); + Badge morningAdult = BadgeFixture.badge(member.getId(), BadgeType.MORNING_ADULT); + Badge nightBirth = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_BIRTH); + List badges = List.of(morningBirth, morningAdult, nightBirth); + 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(); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // 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("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].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_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(); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // 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("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].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", "nick"); + memberRepository.save(friend); + + Badge morningBirth = BadgeFixture.badge(friend.getId(), BadgeType.MORNING_BIRTH); + Badge morningAdult = BadgeFixture.badge(friend.getId(), BadgeType.MORNING_ADULT); + Badge nightBirth = BadgeFixture.badge(friend.getId(), BadgeType.NIGHT_BIRTH); + Badge nightAdult = BadgeFixture.badge(friend.getId(), BadgeType.NIGHT_ADULT); + List badges = List.of(morningBirth, morningAdult, nightBirth, nightAdult); + 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(); + + Inventory morningInven = InventoryFixture.inventory(friend.getId(), morning); + morningInven.select(); + + Inventory killerInven = InventoryFixture.inventory(friend.getId(), killer); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // 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().getImage()), + MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), + + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(true) + ).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(); + + Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); + morningInven.select(); + + Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); + killerInven.select(); + inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + + // expected + mockMvc.perform(get("/members/{memberId}", 123L)) + .andExpect(status().is4xxClientError()); + + } } 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/MemberInfoSearchFixture.java b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java new file mode 100644 index 00000000..9c0c4d26 --- /dev/null +++ b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java @@ -0,0 +1,36 @@ +package com.moabam.support.fixture; + +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 = "profileuri"; + private static final String INTRO = "intro"; + private static final long TOTAL_CERTIFY_COUNT = 15; + + public static List friendMemberInfo() { + return friendMemberInfo(TOTAL_CERTIFY_COUNT); + } + + public static List friendMemberInfo(long total) { + return List.of( + new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, total, BadgeType.MORNING_BIRTH, + 0, 0, 0), + new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, total, BadgeType.NIGHT_BIRTH, + 0, 0, 0) + ); + } + + public static List myInfo() { + return List.of( + new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, TOTAL_CERTIFY_COUNT, BadgeType.MORNING_BIRTH, + 0, 0, 0), + new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, TOTAL_CERTIFY_COUNT, BadgeType.NIGHT_BIRTH, + 0, 0, 0) + ); + } +} From aedcd68c6497dee5c6869edce7bdbd6db98427f1 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 24 Nov 2023 18:34:29 +0900 Subject: [PATCH 079/185] =?UTF-8?q?refactor:=20nginx=20=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EB=A6=AC=ED=8F=AC=EB=A7=B7=ED=8C=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- nginx/conf.d/header.conf | 8 ++++---- nginx/templates/http-server.template | 4 ++-- nginx/templates/ssl-server.template | 6 +++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/nginx/conf.d/header.conf b/nginx/conf.d/header.conf index 0ffa54e9..59deea39 100644 --- a/nginx/conf.d/header.conf +++ b/nginx/conf.d/header.conf @@ -1,9 +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 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; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; diff --git a/nginx/templates/http-server.template b/nginx/templates/http-server.template index 8b7a1f1e..f4c91d91 100644 --- a/nginx/templates/http-server.template +++ b/nginx/templates/http-server.template @@ -1,6 +1,6 @@ server { - listen 80; - server_name ${SERVER_DOMAIN}; + listen 80; + server_name ${SERVER_DOMAIN}; location / { return 301 https://$http_host$request_uri; diff --git a/nginx/templates/ssl-server.template b/nginx/templates/ssl-server.template index 3ed5f615..8bd2f677 100644 --- a/nginx/templates/ssl-server.template +++ b/nginx/templates/ssl-server.template @@ -1,13 +1,13 @@ server { listen 443 ssl; - server_name ${SERVER_DOMAIN}; + server_name ${SERVER_DOMAIN}; - ssl_certificate /etc/letsencrypt/live/${SERVER_DOMAIN}/fullchain.pem; + 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 / { + location / { proxy_pass http://backend; } } From 9e35528206b69958e77ea5b6eaaac007515eab7c Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 24 Nov 2023 18:34:57 +0900 Subject: [PATCH 080/185] =?UTF-8?q?hotfix:=20CorsFilter=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moabam/global/auth/filter/CorsFilter.java | 73 +++++++++++++++++++ .../com/moabam/global/config/WebConfig.java | 18 ----- .../MemberAuthorizeControllerTest.java | 21 ++++-- .../filter/AuthorizationFilterTest.java | 2 +- .../common/WithoutFilterSupporter.java | 16 +++- 5 files changed, 104 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/moabam/global/auth/filter/CorsFilter.java 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..c113f156 --- /dev/null +++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java @@ -0,0 +1,73 @@ +package com.moabam.global.auth.filter; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +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.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; + + @Value("${allow}") + private String allowOrigin; + + @Override + protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, + FilterChain filterChain) throws ServletException, IOException { + + try { + if (!secureMatch(httpServletRequest, allowOrigin)) { + throw new UnauthorizedException(ErrorMessage.INVALID_REQUEST_URL); + } + } catch (UnauthorizedException unauthorizedException) { + log.error("{}, {}", httpServletRequest.getHeader("referer"), allowOrigin); + handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, + unauthorizedException); + + return; + } + + httpServletResponse.setHeader("Access-Control-Allow-Origin", allowOrigin); + 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 boolean secureMatch(HttpServletRequest request, String origin) { + return request.getHeader("referer").contains(origin); + } + + public boolean isOption(String method) { + return HttpMethod.OPTIONS.name().equals(method); + } +} diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index b72e98af..387714cb 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -2,13 +2,11 @@ import java.util.List; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import com.moabam.api.application.auth.mapper.PathMapper; @@ -19,22 +17,6 @@ @EnableScheduling public class WebConfig implements WebMvcConfigurer { - private static final String ALLOWED_METHOD_NAMES = "GET,HEAD,POST,PUT,DELETE,TRACE,OPTIONS,PATCH"; - private static final String ALLOW_ORIGIN_PATTERN = "[a-z]+\\.moabam.com"; - - @Value("${allow}") - private String allowLocalHost; - - @Override - public void addCorsMappings(final CorsRegistry registry) { - registry.addMapping("/**") - .allowedOriginPatterns(ALLOW_ORIGIN_PATTERN, allowLocalHost) - .allowedMethods(ALLOWED_METHOD_NAMES.split(",")) - .allowedHeaders("*") - .allowCredentials(true) - .maxAge(3600); - } - @Override public void addArgumentResolvers(List resolvers) { resolvers.add(handlerMethodArgumentResolver()); diff --git a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java index fa304209..0f5d04d7 100644 --- a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java @@ -1,10 +1,16 @@ 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 static org.mockito.BDDMockito.any; +import static org.mockito.BDDMockito.doReturn; +import static org.mockito.BDDMockito.willReturn; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -37,6 +43,7 @@ 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; @@ -58,6 +65,9 @@ class MemberAuthorizeControllerTest { @SpyBean AuthorizationService authorizationService; + @SpyBean + CorsFilter corsFilter; + @Autowired OAuthConfig oAuthConfig; @@ -77,6 +87,7 @@ void setUp() { RestTemplate restTemplate = restTemplateBuilder.build(); ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + willReturn(true).given(corsFilter).secureMatch(any(), any()); } @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java index d4d0e8eb..31f6303a 100644 --- a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -139,7 +139,7 @@ void filter_have_any_refresh_token_error() throws ServletException, IOException eq(null), any(UnauthorizedException.class)); } - @DisplayName("새로운 도큰 발급 성공") + @DisplayName("새로운 토큰 발급 성공") @Test void issue_new_token_success() throws ServletException, IOException { // given diff --git a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java index 9645716a..ae2b827a 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -1,15 +1,18 @@ package com.moabam.support.common; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.willReturn; 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.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; @ExtendWith({FilterProcessExtension.class}) @@ -18,8 +21,17 @@ public class WithoutFilterSupporter { @MockBean private PathResolver pathResolver; + @MockBean + private DefaultHandlerExceptionResolver handlerExceptionResolver; + + @SpyBean + private CorsFilter corsFilter; + @BeforeEach void setUpMock() { + willReturn(true) + .given(corsFilter).secureMatch(any(), any()); + willReturn(Optional.of(PathResolver.Path.builder() .uri("/") .role(Role.USER) From 4b1c2bb1491790f4fb9f34d97359169974e9be55 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 24 Nov 2023 19:09:46 +0900 Subject: [PATCH 081/185] =?UTF-8?q?refactor:=20=EB=B0=A9/=EB=A3=A8?= =?UTF-8?q?=ED=8B=B4=20=EC=A0=84=EC=B2=B4=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81=20(#143)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ClockHolder LocalDate 추가 * refactor: RoomService 리팩토링 * refactor: SearchService 리팩토링 * refactor: 방 입장, 퇴장 리팩토링 * refactor: CertifiactionService 리팩토링 * refactor: RoomController 리팩토링 * test: InventorySearchRepository 테스트 추가 * refactor: merge 메서드 네이밍 * refactor: ParticipantMapper 코드리뷰 반영 --- .../api/application/member/MemberService.java | 2 +- ...Service.java => CertificationService.java} | 94 +++++---- .../api/application/room/RoomService.java | 90 ++++----- ...mSearchService.java => SearchService.java} | 191 +++++++++++------- .../room/mapper/CertificationsMapper.java | 34 ++-- .../room/mapper/ParticipantMapper.java | 9 + .../application/room/mapper/RoomMapper.java | 16 +- .../repository/InventorySearchRepository.java | 15 +- .../com/moabam/api/domain/member/Member.java | 29 +-- .../room/repository/RoutineRepository.java | 5 + .../repository/RoutineSearchRepository.java | 37 ---- .../dto/room/CertificationImagesResponse.java | 12 ++ ...mResponse.java => GetAllRoomResponse.java} | 2 +- ...Response.java => GetAllRoomsResponse.java} | 4 +- .../room/TodayCertificateRankResponse.java | 4 +- .../api/presentation/RoomController.java | 76 ++++--- .../global/common/util/BaseImageUrl.java | 8 +- .../global/common/util/ClockHolder.java | 3 + .../global/common/util/GlobalConstant.java | 1 + .../global/common/util/SystemClockHolder.java | 6 + .../application/member/MemberServiceTest.java | 8 +- ...est.java => CertificationServiceTest.java} | 27 +-- .../api/application/room/RoomServiceTest.java | 4 +- ...erviceTest.java => SearchServiceTest.java} | 50 ++--- .../moabam/api/domain/entity/MemberTest.java | 13 +- .../InventorySearchRepositoryTest.java | 26 ++- .../domain/member/MemberRepositoryTest.java | 5 +- .../api/presentation/RoomControllerTest.java | 63 ++++-- 28 files changed, 457 insertions(+), 377 deletions(-) rename src/main/java/com/moabam/api/application/room/{RoomCertificationService.java => CertificationService.java} (70%) rename src/main/java/com/moabam/api/application/room/{RoomSearchService.java => SearchService.java} (61%) delete mode 100644 src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/room/CertificationImagesResponse.java rename src/main/java/com/moabam/api/dto/room/{SearchAllRoomResponse.java => GetAllRoomResponse.java} (90%) rename src/main/java/com/moabam/api/dto/room/{SearchAllRoomsResponse.java => GetAllRoomsResponse.java} (61%) rename src/test/java/com/moabam/api/application/room/{RoomCertificationServiceTest.java => CertificationServiceTest.java} (86%) rename src/test/java/com/moabam/api/application/room/{RoomSearchServiceTest.java => SearchServiceTest.java} (91%) diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 01b3201e..b54bdfc6 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -83,7 +83,7 @@ public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { } private List getDefaultSkin(Long searchId) { - List inventories = inventorySearchRepository.findBirdsDefaultSkin(searchId); + List inventories = inventorySearchRepository.findDefaultSkin(searchId); if (inventories.size() != GlobalConstant.DEFAULT_SKIN_SIZE) { throw new BadRequestException(INVALID_DEFAULT_SKIN_SIZE); } diff --git a/src/main/java/com/moabam/api/application/room/RoomCertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java similarity index 70% rename from src/main/java/com/moabam/api/application/room/RoomCertificationService.java rename to src/main/java/com/moabam/api/application/room/CertificationService.java index 0c3a82c9..60d4b56d 100644 --- a/src/main/java/com/moabam/api/application/room/RoomCertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -1,7 +1,9 @@ package com.moabam.api.application.room; -import static com.moabam.api.domain.image.ImageType.*; -import static com.moabam.global.error.model.ErrorMessage.*; +import static com.moabam.global.error.model.ErrorMessage.DUPLICATED_DAILY_MEMBER_CERTIFICATION; +import static com.moabam.global.error.model.ErrorMessage.INVALID_CERTIFY_TIME; +import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.ROUTINE_NOT_FOUND; import java.time.LocalDate; import java.time.LocalDateTime; @@ -12,9 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.application.image.ImageService; import com.moabam.api.application.member.MemberService; import com.moabam.api.application.room.mapper.CertificationsMapper; import com.moabam.api.domain.bug.BugType; @@ -42,7 +42,9 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class RoomCertificationService { +public class CertificationService { + + private static final int REQUIRED_ROOM_CERTIFICATION = 75; private final RoutineRepository routineRepository; private final CertificationRepository certificationRepository; @@ -51,12 +53,11 @@ public class RoomCertificationService { private final DailyRoomCertificationRepository dailyRoomCertificationRepository; private final DailyMemberCertificationRepository dailyMemberCertificationRepository; private final MemberService memberService; - private final ImageService imageService; private final ClockHolder clockHolder; @Transactional - public void certifyRoom(Long memberId, Long roomId, List multipartFiles) { - LocalDate today = LocalDate.now(); + public void certifyRoom(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(); @@ -70,47 +71,17 @@ public void certifyRoom(Long memberId, Long roomId, List multipar validateCertifyTime(clockHolder.times(), room.getCertifyTime()); validateAlreadyCertified(memberId, roomId, today); - DailyMemberCertification dailyMemberCertification = CertificationsMapper.toDailyMemberCertification(memberId, - roomId, participant); - dailyMemberCertificationRepository.save(dailyMemberCertification); - - member.increaseTotalCertifyCount(); - participant.updateCertifyCount(); - - List result = imageService.uploadImages(multipartFiles, CERTIFICATION); - saveNewCertifications(result, memberId); + certifyMember(memberId, roomId, participant, member, imageUrls); Optional dailyRoomCertification = certificationsSearchRepository.findDailyRoomCertification(roomId, today); if (dailyRoomCertification.isEmpty()) { - List dailyMemberCertifications = - certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, today); - double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), - room.getCurrentUserCount()); - - if (completePercentage >= 75) { - DailyRoomCertification createDailyRoomCertification = CertificationsMapper.toDailyRoomCertification( - roomId, today); - - dailyRoomCertificationRepository.save(createDailyRoomCertification); - - int expAppliedRoomLevel = getRoomLevelAfterExpApply(roomLevel, room); - - List memberIds = dailyMemberCertifications.stream() - .map(DailyMemberCertification::getMemberId) - .toList(); - - memberService.getRoomMembers(memberIds) - .forEach(completedMember -> completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); - - return; - } + certifyRoomIfAvailable(roomId, today, room, bugType, roomLevel); + return; } - if (dailyRoomCertification.isPresent()) { - member.getBug().increaseBug(bugType, roomLevel); - } + member.getBug().increaseBug(bugType, roomLevel); } public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { @@ -138,7 +109,17 @@ private void validateAlreadyCertified(Long memberId, Long roomId, LocalDate toda } } - private void saveNewCertifications(List imageUrls, Long memberId) { + 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(); + participant.updateCertifyCount(); + + saveNewCertifications(memberId, urls); + } + + private void saveNewCertifications(Long memberId, List imageUrls) { List certifications = new ArrayList<>(); for (String imageUrl : imageUrls) { @@ -153,6 +134,23 @@ private void saveNewCertifications(List imageUrls, Long memberId) { 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; @@ -169,4 +167,14 @@ private int getRoomLevelAfterExpApply(int roomLevel, Room room) { 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 -> completedMember.getBug().increaseBug(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 index 4775031f..50592ead 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -1,7 +1,14 @@ package com.moabam.api.application.room; -import static com.moabam.api.domain.room.RoomType.*; -import static com.moabam.global.error.model.ErrorMessage.*; +import static com.moabam.api.domain.room.RoomType.MORNING; +import static com.moabam.api.domain.room.RoomType.NIGHT; +import static com.moabam.global.error.model.ErrorMessage.MEMBER_ROOM_EXCEED; +import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.ROOM_EXIT_MANAGER_FAIL; +import static com.moabam.global.error.model.ErrorMessage.ROOM_MAX_USER_REACHED; +import static com.moabam.global.error.model.ErrorMessage.ROOM_MODIFY_UNAUTHORIZED_REQUEST; +import static com.moabam.global.error.model.ErrorMessage.ROOM_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.WRONG_ROOM_PASSWORD; import java.util.List; @@ -10,6 +17,7 @@ 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; @@ -21,7 +29,6 @@ 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.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; @@ -38,7 +45,6 @@ public class RoomService { private final RoomRepository roomRepository; private final RoutineRepository routineRepository; - private final RoutineSearchRepository routineSearchRepository; private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; private final MemberService memberService; @@ -47,19 +53,15 @@ public class RoomService { public Long createRoom(Long memberId, String nickname, CreateRoomRequest createRoomRequest) { Room room = RoomMapper.toRoomEntity(createRoomRequest); List routines = RoutineMapper.toRoutineEntities(room, createRoomRequest.routines()); - Participant participant = Participant.builder() - .room(room) - .memberId(memberId) - .build(); + Participant participant = ParticipantMapper.toParticipant(room, memberId); - if (!isEnterRoomAvailable(memberId, room.getRoomType())) { - throw new BadRequestException(MEMBER_ROOM_EXCEED); - } - - increaseRoomCount(memberId, room.getRoomType()); + validateEnteredRoomCount(memberId, room.getRoomType()); + Member member = memberService.getById(memberId); + member.enterRoom(room.getRoomType()); participant.enableManager(); room.changeManagerNickname(nickname); + Room savedRoom = roomRepository.save(room); routineRepository.saveAll(routines); participantRepository.save(participant); @@ -79,7 +81,7 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); - List routines = routineSearchRepository.findAllByRoomId(roomId); + List routines = routineRepository.findAllByRoomId(roomId); routineRepository.deleteAll(routines); List newRoutines = RoutineMapper.toRoutineEntities(room, modifyRoomRequest.routines()); @@ -91,13 +93,11 @@ public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequ Room room = roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); validateRoomEnter(memberId, enterRoomRequest.password(), room); + Member member = memberService.getById(memberId); + member.enterRoom(room.getRoomType()); room.increaseCurrentUserCount(); - increaseRoomCount(memberId, room.getRoomType()); - Participant participant = Participant.builder() - .room(room) - .memberId(memberId) - .build(); + Participant participant = ParticipantMapper.toParticipant(room, memberId); participantRepository.save(participant); } @@ -106,11 +106,11 @@ public void exitRoom(Long memberId, Long roomId) { Participant participant = getParticipant(memberId, roomId); Room room = participant.getRoom(); - if (participant.isManager() && room.getCurrentUserCount() != 1) { - throw new BadRequestException(ROOM_EXIT_MANAGER_FAIL); - } + validateRoomExit(participant, room); + + Member member = memberService.getById(memberId); + member.exitRoom(room.getRoomType()); - decreaseRoomCount(memberId, room.getRoomType()); participant.removeRoom(); participantRepository.flush(); participantRepository.delete(participant); @@ -124,7 +124,7 @@ public void exitRoom(Long memberId, Long roomId) { } @Transactional - public void mandateRoomManager(Long managerId, Long roomId, Long memberId) { + public void mandateManager(Long managerId, Long roomId, Long memberId) { Participant managerParticipant = getParticipant(managerId, roomId); Participant memberParticipant = getParticipant(memberId, roomId); validateManagerAuthorization(managerParticipant); @@ -141,13 +141,14 @@ public void mandateRoomManager(Long managerId, Long roomId, Long memberId) { public void deportParticipant(Long managerId, Long roomId, Long memberId) { Participant managerParticipant = getParticipant(managerId, roomId); Participant memberParticipant = getParticipant(memberId, roomId); - Room room = managerParticipant.getRoom(); - validateManagerAuthorization(managerParticipant); + Room room = managerParticipant.getRoom(); participantRepository.delete(memberParticipant); room.decreaseCurrentUserCount(); - decreaseRoomCount(memberId, room.getRoomType()); + + Member member = memberService.getById(memberId); + member.exitRoom(room.getRoomType()); } public void validateRoomById(Long roomId) { @@ -168,9 +169,8 @@ private void validateManagerAuthorization(Participant participant) { } private void validateRoomEnter(Long memberId, String requestPassword, Room room) { - if (!isEnterRoomAvailable(memberId, room.getRoomType())) { - throw new BadRequestException(MEMBER_ROOM_EXCEED); - } + validateEnteredRoomCount(memberId, room.getRoomType()); + if (!StringUtils.isEmpty(requestPassword) && !room.getPassword().equals(requestPassword)) { throw new BadRequestException(WRONG_ROOM_PASSWORD); } @@ -179,38 +179,20 @@ private void validateRoomEnter(Long memberId, String requestPassword, Room room) } } - private boolean isEnterRoomAvailable(Long memberId, RoomType roomType) { + private void validateEnteredRoomCount(Long memberId, RoomType roomType) { Member member = memberService.getById(memberId); if (roomType.equals(MORNING) && member.getCurrentMorningCount() >= 3) { - return false; + throw new BadRequestException(MEMBER_ROOM_EXCEED); } if (roomType.equals(NIGHT) && member.getCurrentNightCount() >= 3) { - return false; - } - - return true; - } - - private void increaseRoomCount(Long memberId, RoomType roomType) { - Member member = memberService.getById(memberId); - - if (roomType.equals(MORNING)) { - member.enterMorningRoom(); - return; + throw new BadRequestException(MEMBER_ROOM_EXCEED); } - - member.enterNightRoom(); } - private void decreaseRoomCount(Long memberId, RoomType roomType) { - Member member = memberService.getById(memberId); - - if (roomType.equals(MORNING)) { - member.exitMorningRoom(); - return; + private void validateRoomExit(Participant participant, Room room) { + if (participant.isManager() && room.getCurrentUserCount() != 1) { + throw new BadRequestException(ROOM_EXIT_MANAGER_FAIL); } - - member.exitNightRoom(); } } diff --git a/src/main/java/com/moabam/api/application/room/RoomSearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java similarity index 61% rename from src/main/java/com/moabam/api/application/room/RoomSearchService.java rename to src/main/java/com/moabam/api/application/room/SearchService.java index fad7eaa8..73ee5cb9 100644 --- a/src/main/java/com/moabam/api/application/room/RoomSearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -1,8 +1,12 @@ 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 static com.moabam.global.common.util.GlobalConstant.NOT_COMPLETED_RANK; +import static com.moabam.global.common.util.GlobalConstant.ROOM_FIXED_SEARCH_SIZE; +import static com.moabam.global.error.model.ErrorMessage.INVENTORY_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.ROOM_DETAILS_ERROR; +import static com.moabam.global.error.model.ErrorMessage.ROOM_MODIFY_UNAUTHORIZED_REQUEST; +import static org.apache.commons.lang3.StringUtils.isEmpty; import java.time.LocalDate; import java.time.Period; @@ -19,6 +23,8 @@ 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; @@ -31,8 +37,11 @@ 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.RoutineSearchRepository; +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; @@ -41,8 +50,6 @@ 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.SearchAllRoomResponse; -import com.moabam.api.dto.room.SearchAllRoomsResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.ForbiddenException; @@ -54,15 +61,16 @@ @Service @RequiredArgsConstructor @Transactional(readOnly = true) -public class RoomSearchService { +public class SearchService { - private final CertificationsSearchRepository certificationsSearchRepository; - private final ParticipantSearchRepository participantSearchRepository; - private final RoutineSearchRepository routineSearchRepository; - private final RoomSearchRepository roomSearchRepository; 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 RoomCertificationService roomCertificationService; private final NotificationService notificationService; private final ClockHolder clockHolder; @@ -76,7 +84,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId, LocalDate certificationsSearchRepository.findSortedDailyMemberCertifications(roomId, date); List routineResponses = getRoutineResponses(roomId); List todayCertificateRankResponses = getTodayCertificateRankResponses(memberId, - roomId, dailyMemberCertifications, date); + roomId, dailyMemberCertifications, date, room.getRoomType()); List certifiedDates = getCertifiedDatesBeforeWeek(roomId); double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), room.getCurrentUserCount()); @@ -86,15 +94,14 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId, LocalDate } public MyRoomsResponse getMyRooms(Long memberId) { - LocalDate today = clockHolder.times().toLocalDate(); + LocalDate today = clockHolder.date(); List myRoomResponses = new ArrayList<>(); List participants = participantSearchRepository.findNotDeletedParticipantsByMemberId(memberId); for (Participant participant : participants) { Room room = participant.getRoom(); - boolean isMemberCertified = roomCertificationService.existsMemberCertification(memberId, room.getId(), - today); - boolean isRoomCertified = roomCertificationService.existsRoomCertification(room.getId(), today); + boolean isMemberCertified = certificationService.existsMemberCertification(memberId, room.getId(), today); + boolean isRoomCertified = certificationService.existsRoomCertification(room.getId(), today); myRoomResponses.add(RoomMapper.toMyRoomResponse(room, isMemberCertified, isRoomCertified)); } @@ -104,24 +111,22 @@ public MyRoomsResponse getMyRooms(Long memberId) { public RoomsHistoryResponse getJoinHistory(Long memberId) { List participants = participantSearchRepository.findAllParticipantsByMemberId(memberId); - List roomHistoryResponses = new ArrayList<>(); + List roomHistoryResponses = participants.stream() + .map(participant -> { + if (participant.getRoom() == null) { + return RoomMapper.toRoomHistoryResponse(null, participant.getDeletedRoomTitle(), participant); + } - for (Participant participant : participants) { - if (participant.getRoom() == null) { - roomHistoryResponses.add(RoomMapper.toRoomHistoryResponse(null, - participant.getDeletedRoomTitle(), participant)); - - continue; - } + Room room = participant.getRoom(); - roomHistoryResponses.add(RoomMapper.toRoomHistoryResponse(participant.getRoom().getId(), - participant.getRoom().getTitle(), participant)); - } + return RoomMapper.toRoomHistoryResponse(room.getId(), room.getTitle(), participant); + }) + .toList(); return RoomMapper.toRoomsHistoryResponse(roomHistoryResponses); } - public ManageRoomResponse getRoomDetailsBeforeModification(Long memberId, Long roomId) { + public ManageRoomResponse getRoomForModification(Long memberId, Long roomId) { Participant participant = participantSearchRepository.findOne(memberId, roomId) .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); @@ -132,13 +137,14 @@ public ManageRoomResponse getRoomDetailsBeforeModification(Long memberId, Long r Room room = participant.getRoom(); List routineResponses = getRoutineResponses(roomId); List participants = participantSearchRepository.findParticipantsByRoomId(roomId); - List memberIds = participants.stream().map(Participant::getMemberId).toList(); + 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.times().toLocalDate()); + int contributionPoint = calculateContributionPoint(member.getId(), participants, clockHolder.date()); participantResponses.add(ParticipantMapper.toParticipantResponse(member, contributionPoint)); } @@ -146,16 +152,17 @@ public ManageRoomResponse getRoomDetailsBeforeModification(Long memberId, Long r return RoomMapper.toManageRoomResponse(room, routineResponses, participantResponses); } - public SearchAllRoomsResponse searchAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { - List searchAllRoomResponses = new ArrayList<>(); + public GetAllRoomsResponse getAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { + List getAllRoomResponse = new ArrayList<>(); List rooms = new ArrayList<>(roomSearchRepository.findAllWithNoOffset(roomType, roomId)); - boolean hasNext = isHasNext(searchAllRoomResponses, rooms); + boolean hasNext = isHasNext(getAllRoomResponse, rooms); - return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + return RoomMapper.toSearchAllRoomsResponse(hasNext, getAllRoomResponse); } - public SearchAllRoomsResponse search(String keyword, @Nullable RoomType roomType, @Nullable Long roomId) { - List searchAllRoomResponses = new ArrayList<>(); + // TODO: full-text search 로 바꾸면서 리팩토링 예정 + public GetAllRoomsResponse searchRooms(String keyword, @Nullable RoomType roomType, @Nullable Long roomId) { + List getAllRoomResponse = new ArrayList<>(); List rooms = new ArrayList<>(); if (roomId == null && roomType == null) { @@ -175,12 +182,12 @@ public SearchAllRoomsResponse search(String keyword, @Nullable RoomType roomType roomRepository.searchByKeywordAndRoomIdAndRoomType(keyword, roomType.name(), roomId)); } - boolean hasNext = isHasNext(searchAllRoomResponses, rooms); + boolean hasNext = isHasNext(getAllRoomResponse, rooms); - return RoomMapper.toSearchAllRoomsResponse(hasNext, searchAllRoomResponses); + return RoomMapper.toSearchAllRoomsResponse(hasNext, getAllRoomResponse); } - private boolean isHasNext(List searchAllRoomResponses, List rooms) { + private boolean isHasNext(List getAllRoomResponse, List rooms) { boolean hasNext = false; if (rooms.size() > ROOM_FIXED_SEARCH_SIZE) { @@ -188,31 +195,32 @@ private boolean isHasNext(List searchAllRoomResponses, Li rooms.remove(ROOM_FIXED_SEARCH_SIZE); } - List roomIds = rooms.stream().map(Room::getId).toList(); - List routines = routineSearchRepository.findAllByRoomIds(roomIds); + 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()); - searchAllRoomResponses.add( - RoomMapper.toSearchAllRoomResponse(room, RoutineMapper.toRoutineResponses(filteredRoutines), - isPassword)); + getAllRoomResponse.add(RoomMapper.toSearchAllRoomResponse(room, filteredResponses, isPassword)); } + return hasNext; } private List getRoutineResponses(Long roomId) { - List roomRoutines = routineSearchRepository.findAllByRoomId(roomId); + List roomRoutines = routineRepository.findAllByRoomId(roomId); return RoutineMapper.toRoutineResponses(roomRoutines); } private List getTodayCertificateRankResponses(Long memberId, Long roomId, - List dailyMemberCertifications, LocalDate date) { + List dailyMemberCertifications, LocalDate date, RoomType roomType) { List responses = new ArrayList<>(); List certifications = certificationsSearchRepository.findCertifications(roomId, date); @@ -221,22 +229,27 @@ private List getTodayCertificateRankResponses(Long .map(Participant::getMemberId) .toList()); - List myKnockedNotificationStatusInRoom = notificationService.getMyKnockStatusInRoom( - memberId, roomId, participants); + List knocks = notificationService.getMyKnockStatusInRoom(memberId, roomId, participants); + + List memberIds = members.stream() + .map(Member::getId) + .toList(); + List inventories = inventorySearchRepository.findDefaultInventories(memberIds, roomType.name()); - addCompletedMembers(responses, dailyMemberCertifications, members, certifications, participants, date, - myKnockedNotificationStatusInRoom); - addUncompletedMembers(responses, dailyMemberCertifications, members, participants, date, - myKnockedNotificationStatusInRoom); + responses.addAll(completedMembers(dailyMemberCertifications, members, certifications, participants, date, + knocks, inventories)); + responses.addAll(uncompletedMembers(dailyMemberCertifications, members, participants, date, knocks, + inventories)); return responses; } - private void addCompletedMembers(List responses, + private List completedMembers( List dailyMemberCertifications, List members, - List certifications, List participants, LocalDate date, - List myKnockedNotificationStatusInRoom) { + List certifications, List participants, LocalDate date, List knocks, + List inventories) { + List responses = new ArrayList<>(); int rank = 1; for (DailyMemberCertification certification : dailyMemberCertifications) { @@ -245,24 +258,33 @@ private void addCompletedMembers(List responses, .findAny() .orElseThrow(() -> new NotFoundException(ROOM_DETAILS_ERROR)); - int contributionPoint = calculateContributionPoint(member.getId(), participants, date); - List certificationImageResponses = - CertificationsMapper.toCertificateImageResponses(member.getId(), certifications); + Inventory inventory = inventories.stream() + .filter(i -> i.getMemberId().equals(member.getId())) + .findAny() + .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); - boolean isNotificationSent = myKnockedNotificationStatusInRoom.contains(member.getId()); + String awakeImage = inventory.getItem().getImage(); + String sleepImage = inventory.getItem().getImage(); - TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse( - rank, member, contributionPoint, "https://~awake", "https://~sleep", certificationImageResponses, - isNotificationSent); + 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 void addUncompletedMembers(List responses, - List dailyMemberCertifications, List members, - List participants, LocalDate date, List myKnockedNotificationStatusInRoom) { + private List uncompletedMembers( + List dailyMemberCertifications, List members, List participants, + LocalDate date, List knocks, List inventories) { + + List responses = new ArrayList<>(); List allMemberIds = participants.stream() .map(Participant::getMemberId) @@ -280,14 +302,35 @@ private void addUncompletedMembers(List responses, .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().getImage(); + String sleepImage = inventory.getItem().getImage(); + int contributionPoint = calculateContributionPoint(memberId, participants, date); - boolean isNotificationSent = myKnockedNotificationStatusInRoom.contains(member.getId()); + boolean isNotificationSent = knocks.contains(member.getId()); - TodayCertificateRankResponse response = CertificationsMapper.toTodayCertificateRankResponse(500, member, - contributionPoint, "https://~awake", "https://~sleep", null, isNotificationSent); + 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) { @@ -303,9 +346,11 @@ private int calculateContributionPoint(Long memberId, List particip private List getCertifiedDatesBeforeWeek(Long roomId) { List certifications = certificationsSearchRepository.findDailyRoomCertifications( - roomId, clockHolder.times().toLocalDate()); + roomId, clockHolder.date()); - return certifications.stream().map(DailyRoomCertification::getCertifiedAt).toList(); + return certifications.stream() + .map(DailyRoomCertification::getCertifiedAt) + .toList(); } private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { 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 index 788c03d2..060e3478 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java @@ -1,7 +1,6 @@ package com.moabam.api.application.room.mapper; import java.time.LocalDate; -import java.util.ArrayList; import java.util.List; import com.moabam.api.domain.member.Member; @@ -11,6 +10,7 @@ import com.moabam.api.domain.room.Participant; 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.TodayCertificateRankResponse; import lombok.AccessLevel; @@ -19,29 +19,22 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class CertificationsMapper { - public static List toCertificateImageResponses(Long memberId, - List certifications) { - - List cftImageResponses = new ArrayList<>(); - List filteredCertifications = certifications.stream() - .filter(certification -> certification.getMemberId().equals(memberId)) - .toList(); - - for (Certification certification : filteredCertifications) { - CertificationImageResponse cftImageResponse = CertificationImageResponse.builder() - .routineId(certification.getRoutine().getId()) - .image(certification.getImage()) - .build(); - - cftImageResponses.add(cftImageResponse); - } + public static CertificationImageResponse toCertificateImageResponse(Long routineId, String image) { + return CertificationImageResponse.builder() + .routineId(routineId) + .image(image) + .build(); + } - return cftImageResponses; + 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, - List certificationImageResponses, boolean isNotificationSent) { + CertificationImagesResponse certificationImagesResponses, boolean isNotificationSent) { return TodayCertificateRankResponse.builder() .rank(rank) @@ -52,13 +45,12 @@ public static TodayCertificateRankResponse toTodayCertificateRankResponse(int ra .contributionPoint(contributionPoint) .awakeImage(awakeImage) .sleepImage(sleepImage) - .certificationImage(certificationImageResponses) + .certificationImage(certificationImagesResponses) .build(); } public static DailyMemberCertification toDailyMemberCertification(Long memberId, Long roomId, Participant participant) { - return DailyMemberCertification.builder() .memberId(memberId) .roomId(roomId) 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 index 0a566e70..3a4ec1da 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/ParticipantMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/ParticipantMapper.java @@ -1,6 +1,8 @@ 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; @@ -9,6 +11,13 @@ @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()) 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 index 353c2f99..7e7a9d0c 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -6,6 +6,8 @@ import com.moabam.api.domain.room.Participant; import com.moabam.api.domain.room.Room; 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; @@ -14,8 +16,6 @@ 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.SearchAllRoomResponse; -import com.moabam.api.dto.room.SearchAllRoomsResponse; import com.moabam.api.dto.room.TodayCertificateRankResponse; import lombok.AccessLevel; @@ -107,9 +107,9 @@ public static ManageRoomResponse toManageRoomResponse(Room room, List routineResponses, + public static GetAllRoomResponse toSearchAllRoomResponse(Room room, List routineResponses, boolean isPassword) { - return SearchAllRoomResponse.builder() + return GetAllRoomResponse.builder() .id(room.getId()) .title(room.getTitle()) .image(room.getRoomImage()) @@ -124,11 +124,11 @@ public static SearchAllRoomResponse toSearchAllRoomResponse(Room room, List searchAllRoomResponses) { - return SearchAllRoomsResponse.builder() + public static GetAllRoomsResponse toSearchAllRoomsResponse(boolean hasNext, + List getAllRoomResponse) { + return GetAllRoomsResponse.builder() .hasNext(hasNext) - .rooms(searchAllRoomResponses) + .rooms(getAllRoomResponse) .build(); } } 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 index c4f85cb5..431cf898 100644 --- a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java @@ -54,13 +54,24 @@ public List findItems(Long memberId, ItemType type) { .fetch(); } - public List findBirdsDefaultSkin(Long searchId) { + public List findDefaultSkin(Long memberId) { return jpaQueryFactory.selectFrom(inventory) .join(inventory.item) .on(inventory.item.id.eq(item.id)) .where( - inventory.memberId.eq(searchId), + 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/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index 668ea534..b013d7fc 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -10,6 +10,7 @@ import org.hibernate.annotations.SQLDelete; import com.moabam.api.domain.bug.Bug; +import com.moabam.api.domain.room.RoomType; import com.moabam.global.common.entity.BaseTimeEntity; import com.moabam.global.common.util.BaseImageUrl; @@ -83,28 +84,30 @@ private Member(Long id, String socialId, Bug bug) { this.id = id; this.socialId = requireNonNull(socialId); this.nickname = createNickName(); - this.profileImage = BaseImageUrl.PROFILE_URL; + this.profileImage = BaseImageUrl.MEMBER_PROFILE_URL; this.bug = requireNonNull(bug); this.role = Role.USER; } - public void enterMorningRoom() { - currentMorningCount++; - } + public void enterRoom(RoomType roomType) { + if (roomType.equals(RoomType.MORNING)) { + this.currentMorningCount++; + return; + } - public void enterNightRoom() { - currentNightCount++; + if (roomType.equals(RoomType.NIGHT)) { + this.currentNightCount++; + } } - public void exitMorningRoom() { - if (currentMorningCount > 0) { - currentMorningCount--; + public void exitRoom(RoomType roomType) { + if (roomType.equals(RoomType.MORNING) && currentMorningCount > 0) { + this.currentMorningCount--; + return; } - } - public void exitNightRoom() { - if (currentNightCount > 0) { - currentNightCount--; + if (roomType.equals(RoomType.NIGHT) && currentNightCount > 0) { + this.currentNightCount--; } } 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 index d4f0e1b9..add3c3be 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoutineRepository.java @@ -1,9 +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/domain/room/repository/RoutineSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java deleted file mode 100644 index 087d22b6..00000000 --- a/src/main/java/com/moabam/api/domain/room/repository/RoutineSearchRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.moabam.api.domain.room.repository; - -import static com.moabam.api.domain.room.QRoutine.*; - -import java.util.List; - -import org.springframework.stereotype.Repository; - -import com.moabam.api.domain.room.Routine; -import com.querydsl.jpa.impl.JPAQueryFactory; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class RoutineSearchRepository { - - private final JPAQueryFactory jpaQueryFactory; - - public List findAllByRoomId(Long roomId) { - return jpaQueryFactory - .selectFrom(routine) - .where( - routine.room.id.eq(roomId) - ) - .fetch(); - } - - public List findAllByRoomIds(List roomIds) { - return jpaQueryFactory - .selectFrom(routine) - .where( - routine.room.id.in(roomIds) - ) - .fetch(); - } -} 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/SearchAllRoomResponse.java b/src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java similarity index 90% rename from src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java rename to src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java index cdb35ab4..bff4f0ba 100644 --- a/src/main/java/com/moabam/api/dto/room/SearchAllRoomResponse.java +++ b/src/main/java/com/moabam/api/dto/room/GetAllRoomResponse.java @@ -7,7 +7,7 @@ import lombok.Builder; @Builder -public record SearchAllRoomResponse( +public record GetAllRoomResponse( Long id, String title, String image, diff --git a/src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java b/src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java similarity index 61% rename from src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java rename to src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java index 1d2eb960..bb648e72 100644 --- a/src/main/java/com/moabam/api/dto/room/SearchAllRoomsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/GetAllRoomsResponse.java @@ -5,9 +5,9 @@ import lombok.Builder; @Builder -public record SearchAllRoomsResponse( +public record GetAllRoomsResponse( boolean hasNext, - List rooms + List rooms ) { } diff --git a/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java index 414be53a..c35829d4 100644 --- a/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java +++ b/src/main/java/com/moabam/api/dto/room/TodayCertificateRankResponse.java @@ -1,7 +1,5 @@ package com.moabam.api.dto.room; -import java.util.List; - import lombok.Builder; @Builder @@ -14,7 +12,7 @@ public record TodayCertificateRankResponse( int contributionPoint, String awakeImage, String sleepImage, - List certificationImage + CertificationImagesResponse certificationImage ) { } diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 9229e9df..c3d720fd 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -17,18 +17,20 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.application.room.RoomCertificationService; -import com.moabam.api.application.room.RoomSearchService; +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.room.RoomType; 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.SearchAllRoomsResponse; import com.moabam.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; @@ -41,30 +43,33 @@ public class RoomController { private final RoomService roomService; - private final RoomSearchService roomSearchService; - private final RoomCertificationService roomCertificationService; + 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) { - + public Long createRoom(@Auth AuthMember authMember, @Valid @RequestBody CreateRoomRequest createRoomRequest) { return roomService.createRoom(authMember.id(), authMember.nickname(), createRoomRequest); } - @GetMapping("/{roomId}") + @GetMapping @ResponseStatus(HttpStatus.OK) - public ManageRoomResponse getRoomDetailsBeforeModification(@Auth AuthMember authMember, - @PathVariable("roomId") Long roomId) { + public GetAllRoomsResponse getAllRooms(@RequestParam(value = "roomType", required = false) RoomType roomType, + @RequestParam(value = "roomId", required = false) Long roomId) { + return searchService.getAllRooms(roomType, roomId); + } - return roomSearchService.getRoomDetailsBeforeModification(authMember.id(), 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) { - + public void modifyRoom(@Auth AuthMember authMember, @Valid @RequestBody ModifyRoomRequest modifyRoomRequest, + @PathVariable("roomId") Long roomId) { roomService.modifyRoom(authMember.id(), roomId, modifyRoomRequest); } @@ -72,7 +77,6 @@ public void modifyRoom(@Auth AuthMember authMember, @ResponseStatus(HttpStatus.OK) public void enterRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, @Valid @RequestBody EnterRoomRequest enterRoomRequest) { - roomService.enterRoom(authMember.id(), roomId, enterRoomRequest); } @@ -84,62 +88,50 @@ public void exitRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long r @GetMapping("/{roomId}/{date}") @ResponseStatus(HttpStatus.OK) - public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, - @PathVariable("roomId") Long roomId, @PathVariable("date") LocalDate date) { - - return roomSearchService.getRoomDetails(authMember.id(), roomId, date); + 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, @RequestPart List multipartFiles) { - - roomCertificationService.certifyRoom(authMember.id(), roomId, multipartFiles); + List imageUrls = imageService.uploadImages(multipartFiles, ImageType.CERTIFICATION); + certificationService.certifyRoom(authMember.id(), roomId, imageUrls); } @PutMapping("/{roomId}/members/{memberId}/mandate") @ResponseStatus(HttpStatus.OK) - public void mandateManager(@Auth AuthMember authMember, - @PathVariable("roomId") Long roomId, @PathVariable("memberId") Long memberId) { - - roomService.mandateRoomManager(authMember.id(), roomId, memberId); + 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) { - + 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 roomSearchService.getMyRooms(authMember.id()); + return searchService.getMyRooms(authMember.id()); } @GetMapping("/join-history") @ResponseStatus(HttpStatus.OK) public RoomsHistoryResponse getJoinHistory(@Auth AuthMember authMember) { - return roomSearchService.getJoinHistory(authMember.id()); - } - - @GetMapping - @ResponseStatus(HttpStatus.OK) - public SearchAllRoomsResponse searchAllRooms(@RequestParam(value = "roomType", required = false) RoomType roomType, - @RequestParam(value = "roomId", required = false) Long roomId) { - - return roomSearchService.searchAllRooms(roomType, roomId); + return searchService.getJoinHistory(authMember.id()); } @GetMapping("/search") @ResponseStatus(HttpStatus.OK) - public SearchAllRoomsResponse search(@RequestParam(value = "keyword") String keyword, + public GetAllRoomsResponse searchRooms(@RequestParam(value = "keyword") String keyword, @RequestParam(value = "roomType", required = false) RoomType roomType, @RequestParam(value = "roomId", required = false) Long roomId) { - - return roomSearchService.search(keyword, roomType, roomId); + return searchService.searchRooms(keyword, roomType, roomId); } } diff --git a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java index d13f36ff..99544648 100644 --- a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -6,5 +6,11 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class BaseImageUrl { - public static final String PROFILE_URL = "/profile/baseUrl"; + 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 MEMBER_PROFILE_URL = "/profile/baseUrl"; } diff --git a/src/main/java/com/moabam/global/common/util/ClockHolder.java b/src/main/java/com/moabam/global/common/util/ClockHolder.java index 414ce25c..1ba7a0c5 100644 --- a/src/main/java/com/moabam/global/common/util/ClockHolder.java +++ b/src/main/java/com/moabam/global/common/util/ClockHolder.java @@ -1,8 +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/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 19e2a26d..ec45d2ec 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -14,6 +14,7 @@ public class GlobalConstant { public static final String SPACE = " "; 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; diff --git a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java index 8662d0da..79396d91 100644 --- a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java +++ b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java @@ -1,5 +1,6 @@ package com.moabam.global.common.util; +import java.time.LocalDate; import java.time.LocalDateTime; import org.springframework.stereotype.Component; @@ -11,4 +12,9 @@ public class SystemClockHolder implements ClockHolder { public LocalDateTime times() { return LocalDateTime.now(); } + + @Override + public LocalDate date() { + return LocalDate.now(); + } } diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index 8db4966b..c1d9025c 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -147,7 +147,7 @@ void search_my_info_success(@WithMember AuthMember authMember) { given(memberSearchRepository.findMemberAndBadges(authMember.id(), true)) .willReturn(MemberInfoSearchFixture.friendMemberInfo(total)); - given(inventorySearchRepository.findBirdsDefaultSkin(authMember.id())) + given(inventorySearchRepository.findDefaultSkin(authMember.id())) .willReturn(List.of( InventoryFixture.inventory(authMember.id(), morning), InventoryFixture.inventory(authMember.id(), night))); @@ -177,7 +177,7 @@ void success(@WithMember AuthMember authMember) { given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) .willReturn(MemberInfoSearchFixture.myInfo()); - given(inventorySearchRepository.findBirdsDefaultSkin(searchId)).willReturn(List.of(morningSkin, nightSkin)); + given(inventorySearchRepository.findDefaultSkin(searchId)).willReturn(List.of(morningSkin, nightSkin)); // when MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); @@ -193,7 +193,7 @@ void failBy_underSize(@WithMember AuthMember authMember) { // given given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) .willReturn(MemberInfoSearchFixture.friendMemberInfo()); - given(inventorySearchRepository.findBirdsDefaultSkin(anyLong())).willReturn(List.of()); + given(inventorySearchRepository.findDefaultSkin(anyLong())).willReturn(List.of()); // when assertThatThrownBy(() -> memberService.searchInfo(authMember, 123L)) @@ -215,7 +215,7 @@ void failBy_overSize(@WithMember AuthMember authMember) { given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) .willReturn(MemberInfoSearchFixture.myInfo()); - given(inventorySearchRepository.findBirdsDefaultSkin(searchId)) + given(inventorySearchRepository.findDefaultSkin(searchId)) .willReturn(List.of(morningSkin, nightSkin, killSkin)); // when diff --git a/src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java similarity index 86% rename from src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java rename to src/test/java/com/moabam/api/application/room/CertificationServiceTest.java index a103d1e9..ee3bacb1 100644 --- a/src/test/java/com/moabam/api/application/room/RoomCertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -1,8 +1,10 @@ package com.moabam.api.application.room; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.lenient; +import static org.mockito.BDDMockito.spy; import java.time.LocalDate; import java.time.LocalDateTime; @@ -18,12 +20,9 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.mock.web.MockMultipartFile; -import org.springframework.web.multipart.MultipartFile; 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.domain.member.Member; import com.moabam.api.domain.room.DailyMemberCertification; import com.moabam.api.domain.room.DailyRoomCertification; @@ -43,10 +42,10 @@ import com.moabam.support.fixture.RoomFixture; @ExtendWith(MockitoExtension.class) -class RoomCertificationServiceTest { +class CertificationServiceTest { @InjectMocks - private RoomCertificationService roomCertificationService; + private CertificationService certificationService; @Mock private MemberService memberService; @@ -118,14 +117,12 @@ void already_certified_room_routine_success() { // given List routines = RoomFixture.routines(room); DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today); - MockMultipartFile image = RoomFixture.makeMultipartFile1(); - List images = List.of(image, image, image); List uploadImages = new ArrayList<>(); uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); - given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); + given(clockHolder.date()).willReturn(today); given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); given(memberService.getById(memberId)).willReturn(member1); given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); @@ -134,7 +131,7 @@ void already_certified_room_routine_success() { Optional.of(dailyRoomCertification)); // when - roomCertificationService.certifyRoom(memberId, roomId, images); + certificationService.certifyRoom(memberId, roomId, uploadImages); // then assertThat(member1.getBug().getMorningBug()).isEqualTo(12); @@ -146,16 +143,14 @@ void already_certified_room_routine_success() { void not_certified_room_routine_success() { // given List routines = RoomFixture.routines(room); - MockMultipartFile image = RoomFixture.makeMultipartFile1(); List dailyMemberCertifications = RoomFixture.dailyMemberCertifications(roomId, participant); - List images = List.of(image, image, image); List uploadImages = new ArrayList<>(); uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); - given(imageService.uploadImages(images, ImageType.CERTIFICATION)).willReturn(uploadImages); given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); + given(clockHolder.date()).willReturn(today); given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); given(memberService.getById(memberId)).willReturn(member1); given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); @@ -167,7 +162,7 @@ void not_certified_room_routine_success() { given(memberService.getRoomMembers(anyList())).willReturn(List.of(member1, member2, member3)); // when - roomCertificationService.certifyRoom(memberId, roomId, images); + certificationService.certifyRoom(memberId, roomId, uploadImages); // then assertThat(member1.getBug().getMorningBug()).isEqualTo(12); diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java index fdecd0e5..b0038193 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -131,7 +131,7 @@ void room_manager_mandate_success() { given(memberService.getById(2L)).willReturn(member); // when - roomService.mandateRoomManager(managerId, room.getId(), memberId); + roomService.mandateManager(managerId, room.getId(), memberId); // then assertThat(managerParticipant.isManager()).isFalse(); @@ -157,7 +157,7 @@ void room_manager_mandate_fail() { Optional.of(managerParticipant)); // when, then - assertThatThrownBy(() -> roomService.mandateRoomManager(managerId, 1L, memberId)) + assertThatThrownBy(() -> roomService.mandateManager(managerId, 1L, memberId)) .isInstanceOf(ForbiddenException.class); } } diff --git a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java similarity index 91% rename from src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java rename to src/test/java/com/moabam/api/application/room/SearchServiceTest.java index f1239539..70e26d2f 100644 --- a/src/test/java/com/moabam/api/application/room/RoomSearchServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java @@ -26,18 +26,18 @@ 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.RoutineSearchRepository; +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.api.dto.room.SearchAllRoomsResponse; import com.moabam.global.common.util.ClockHolder; import com.moabam.support.fixture.RoomFixture; @ExtendWith(MockitoExtension.class) -class RoomSearchServiceTest { +class SearchServiceTest { @InjectMocks - private RoomSearchService roomSearchService; + private SearchService searchService; @Mock private CertificationsSearchRepository certificationsSearchRepository; @@ -46,7 +46,7 @@ class RoomSearchServiceTest { private ParticipantSearchRepository participantSearchRepository; @Mock - private RoutineSearchRepository routineSearchRepository; + private RoutineRepository routineRepository; @Mock private RoomSearchRepository roomSearchRepository; @@ -55,7 +55,7 @@ class RoomSearchServiceTest { private MemberService memberService; @Mock - private RoomCertificationService certificationService; + private CertificationService certificationService; @Mock private RoomRepository roomRepository; @@ -91,10 +91,10 @@ void get_my_rooms_success() { given(certificationService.existsRoomCertification(room2.getId(), today)).willReturn(false); given(certificationService.existsRoomCertification(room3.getId(), today)).willReturn(false); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); // when - MyRoomsResponse myRooms = roomSearchService.getMyRooms(memberId); + MyRoomsResponse myRooms = searchService.getMyRooms(memberId); // then assertThat(myRooms.participatingRooms()).hasSize(3); @@ -133,7 +133,7 @@ void get_my_join_history_success() { given(participantSearchRepository.findAllParticipantsByMemberId(memberId)).willReturn(participants); // when - RoomsHistoryResponse response = roomSearchService.getJoinHistory(memberId); + RoomsHistoryResponse response = searchService.getJoinHistory(memberId); // then assertThat(response.roomHistory()).hasSize(3); @@ -253,16 +253,16 @@ void search_all_morning_night_rooms_success() { routine28); given(roomSearchRepository.findAllWithNoOffset(null, null)).willReturn(rooms); - given(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); // when - SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.searchAllRooms(null, null); + GetAllRoomsResponse getAllRoomsResponse = searchService.getAllRooms(null, null); // then - assertThat(searchAllRoomsResponse.hasNext()).isTrue(); - assertThat(searchAllRoomsResponse.rooms()).hasSize(10); - assertThat(searchAllRoomsResponse.rooms().get(0).id()).isEqualTo(1L); - assertThat(searchAllRoomsResponse.rooms().get(9).id()).isEqualTo(10L); + 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("아침, 저녁 전체 방 조회 성공, 마지막 페이 조회, 다음 페이지 없음") @@ -306,16 +306,16 @@ void search_last_page_all_morning_night_rooms_success() { routine28); given(roomSearchRepository.findAllWithNoOffset(null, 10L)).willReturn(rooms); - given(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); // when - SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.searchAllRooms(null, 10L); + GetAllRoomsResponse getAllRoomsResponse = searchService.getAllRooms(null, 10L); // then - assertThat(searchAllRoomsResponse.hasNext()).isFalse(); - assertThat(searchAllRoomsResponse.rooms()).hasSize(4); - assertThat(searchAllRoomsResponse.rooms().get(0).id()).isEqualTo(11L); - assertThat(searchAllRoomsResponse.rooms().get(3).id()).isEqualTo(14L); + 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("전체 방 제목, 방장 이름, 루틴 내용으로 검색 성공 - 최초 조회") @@ -405,13 +405,13 @@ void search_room_by_title_manager_nickname_routine_success() { routine25, routine26, routine27, routine28); given(roomRepository.searchByKeyword("번째")).willReturn(rooms); - given(routineSearchRepository.findAllByRoomIds(anyList())).willReturn(routines); + given(routineRepository.findAllByRoomIdIn(anyList())).willReturn(routines); // when - SearchAllRoomsResponse searchAllRoomsResponse = roomSearchService.search("번째", null, null); + GetAllRoomsResponse getAllRoomsResponse = searchService.searchRooms("번째", null, null); // then - assertThat(searchAllRoomsResponse.hasNext()).isTrue(); - assertThat(searchAllRoomsResponse.rooms()).hasSize(10); + assertThat(getAllRoomsResponse.hasNext()).isTrue(); + assertThat(getAllRoomsResponse.rooms()).hasSize(10); } } diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index 8e3ae4f8..b3168af3 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -10,6 +10,7 @@ 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.global.common.util.BaseImageUrl; import com.moabam.support.fixture.MemberFixture; @@ -40,7 +41,7 @@ void create_member_noImage_success() { .build(); assertAll( - () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.PROFILE_URL), + () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.MEMBER_PROFILE_URL), () -> assertThat(member.getRole()).isEqualTo(Role.USER), () -> assertThat(member.getBug().getNightBug()).isZero(), () -> assertThat(member.getBug().getGoldenBug()).isZero(), @@ -82,10 +83,10 @@ void member_room_enter_success() { // when int beforeMorningCount = member.getCurrentMorningCount(); - member.enterMorningRoom(); + member.enterRoom(RoomType.MORNING); int beforeNightCount = member.getCurrentNightCount(); - member.enterNightRoom(); + member.enterRoom(RoomType.NIGHT); // then assertThat(member.getCurrentMorningCount()).isEqualTo(beforeMorningCount + 1); @@ -99,12 +100,12 @@ void member_room_exit_success() { Member member = MemberFixture.member(); // when - member.exitMorningRoom(); - member.exitNightRoom(); + member.exitRoom(RoomType.MORNING); + member.exitRoom(RoomType.NIGHT); // then assertThat(member.getCurrentMorningCount()).isZero(); - assertThat(member.getCurrentMorningCount()).isZero(); + assertThat(member.getCurrentNightCount()).isZero(); } } } 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 index cfeddcea..2fdd57e9 100644 --- a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -18,6 +18,7 @@ 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; @@ -111,6 +112,27 @@ void find_default_success() { assertThat(actual).isPresent().contains(inventory); } + @DisplayName("여러 회원의 밤 타입에 적용된 인벤토리를 조회한다.") + @Test + void find_all_default_type_night_success() { + // given + Member member1 = memberRepository.save(member("1", "회원1")); + Member member2 = memberRepository.save(member("2", "회원2")); + Item item = itemRepository.save(nightMageSkin()); + Inventory inventory1 = inventoryRepository.save(inventory(member1.getId(), item)); + Inventory inventory2 = inventoryRepository.save(inventory(member2.getId(), item)); + inventory1.select(); + inventory2.select(); + + // 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 { @@ -120,7 +142,7 @@ class FindDefaultBird { void bird_find_success() { // given Member member = MemberFixture.member(); - member.enterMorningRoom(); + member.exitRoom(RoomType.MORNING); memberRepository.save(member); Item night = ItemFixture.nightMageSkin(); @@ -138,7 +160,7 @@ void bird_find_success() { inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); // when - List inventories = inventorySearchRepository.findBirdsDefaultSkin(member.getId()); + List inventories = inventorySearchRepository.findDefaultSkin(member.getId()); // then assertThat(inventories).hasSize(2); diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index 071605d1..ff6d4403 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -16,6 +16,7 @@ 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.RoomType; import com.moabam.api.domain.room.repository.ParticipantRepository; import com.moabam.api.domain.room.repository.RoomRepository; import com.moabam.api.dto.member.MemberInfo; @@ -124,7 +125,7 @@ void member_not_found() { void search_info_success() { // given Member member = MemberFixture.member(); - member.enterMorningRoom(); + member.enterRoom(RoomType.MORNING); memberRepository.save(member); Badge morningBirth = BadgeFixture.badge(member.getId(), BadgeType.MORNING_BIRTH); @@ -149,7 +150,7 @@ void search_info_success() { void no_badges_search_success() { // given Member member = MemberFixture.member(); - member.enterMorningRoom(); + member.enterRoom(RoomType.MORNING); memberRepository.save(member); // when diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 14d9049d..1e812e7b 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1,11 +1,16 @@ 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 static com.moabam.api.domain.room.RoomType.MORNING; +import static com.moabam.api.domain.room.RoomType.NIGHT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.time.LocalDate; import java.util.ArrayList; @@ -25,6 +30,10 @@ 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; @@ -41,13 +50,14 @@ 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.domain.room.repository.RoutineSearchRepository; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.ModifyRoomRequest; 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; @@ -69,9 +79,6 @@ class RoomControllerTest extends WithoutFilterSupporter { @Autowired private RoutineRepository routineRepository; - @Autowired - private RoutineSearchRepository routineSearchRepository; - @Autowired private ParticipantRepository participantRepository; @@ -90,6 +97,12 @@ class RoomControllerTest extends WithoutFilterSupporter { @Autowired private ParticipantSearchRepository participantSearchRepository; + @Autowired + private ItemRepository itemRepository; + + @Autowired + private InventoryRepository inventoryRepository; + Member member; @BeforeAll @@ -101,11 +114,11 @@ void setUp() { @AfterEach void cleanUp() { while (member.getCurrentMorningCount() > 0) { - member.exitMorningRoom(); + member.exitRoom(MORNING); } while (member.getCurrentNightCount() > 0) { - member.exitNightRoom(); + member.exitRoom(NIGHT); } } @@ -310,7 +323,7 @@ void modify_room_success() throws Exception { .andDo(print()); Room modifiedRoom = roomRepository.findById(room.getId()).orElseThrow(); - List modifiedRoutines = routineSearchRepository.findAllByRoomId(room.getId()); + List modifiedRoutines = routineRepository.findAllByRoomId(room.getId()); assertThat(modifiedRoom.getTitle()).isEqualTo("수정할 방임!"); assertThat(modifiedRoom.getCertifyTime()).isEqualTo(10); @@ -500,7 +513,7 @@ void enter_and_morning_room_over_three_fail() throws Exception { .build(); for (int i = 0; i < 3; i++) { - member.enterMorningRoom(); + member.enterRoom(MORNING); } memberRepository.save(member); @@ -528,7 +541,7 @@ void enter_and_night_room_over_three_fail() throws Exception { .build(); for (int i = 0; i < 3; i++) { - member.enterNightRoom(); + member.enterRoom(NIGHT); } memberRepository.save(member); @@ -715,7 +728,7 @@ void exit_and_decrease_morning_room_count() throws Exception { Participant participant = RoomFixture.participant(room, 1L); for (int i = 0; i < 3; i++) { - member.enterMorningRoom(); + member.enterRoom(RoomType.MORNING); } memberRepository.save(member); @@ -748,7 +761,7 @@ void exit_and_decrease_night_room_count() throws Exception { Participant participant = RoomFixture.participant(room, 1L); for (int i = 0; i < 3; i++) { - member.enterNightRoom(); + member.enterRoom(NIGHT); } memberRepository.save(member); @@ -791,8 +804,20 @@ void get_room_details_test() throws Exception { roomRepository.save(room); routineRepository.saveAll(routines); - memberRepository.save(member2); - memberRepository.save(member3); + 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(); + inventory2.select(); + inventory3.select(); + + itemRepository.save(item); + inventoryRepository.saveAll(List.of(inventory1, inventory2, inventory3)); Participant participant2 = RoomFixture.participant(room, member2.getId()); Participant participant3 = RoomFixture.participant(room, member3.getId()); From 0756c4d817c9163eb8c5ff82fe992c4c69b4e18f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:12:55 +0900 Subject: [PATCH 082/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EB=8C=80=EA=B8=B0?= =?UTF-8?q?=EC=97=B4=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?(#146)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style : Schedule 어노테이션 위치 변경 * refactor: 쿠폰 발행 기간 하루로 통일 및 쿠폰 정보 오픈 날짜 추가 * feat: 쿠폰 발행 가능 날짜 중복 체크 기능 추가 * refactor: Builder 삭제 * test: 쿠폰 관련 테스트 수정 * feat: 쿠폰 발행 관련 레포지토리 기능 구현 및 테스트 * test: 쿠폰 발행 관련 문자열 레디스 기능 구현 및 테스트 * feat: 쿠폰 발행 관련 ZSET 레디스 기능 구현 및 테스트 * test: 쿠폰 발행 컨트롤러 기능 테스트 * test: RestDoc 업데이트 * test: Github Actions 시, Redis ZSET 명령어 못찾는 테스트 Disable --- .../api/application/bug/BugService.java | 2 +- .../coupon/CouponManageService.java | 91 ++++++++ .../coupon/CouponQueueService.java | 36 --- .../api/application/coupon/CouponService.java | 26 +-- .../com/moabam/api/domain/coupon/Coupon.java | 2 +- .../api/domain/coupon/CouponWallet.java | 6 +- .../repository/CouponManageRepository.java | 63 +++++ .../repository/CouponQueueRepository.java | 24 -- .../coupon/repository/CouponRepository.java | 7 +- .../redis/StringRedisRepository.java | 10 +- .../redis/ZSetRedisRepository.java | 21 +- .../api/presentation/CouponController.java | 8 +- .../global/common/util/DynamicQuery.java | 16 -- .../global/error/model/ErrorMessage.java | 1 + src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 6 +- .../resources/static/docs/notification.html | 2 +- .../api/application/bug/BugServiceTest.java | 2 +- .../coupon/CouponManageServiceTest.java | 215 ++++++++++++++++++ .../coupon/CouponQueueServiceTest.java | 83 ------- .../application/coupon/CouponServiceTest.java | 92 +++----- .../api/domain/coupon/CouponWalletTest.java | 5 +- .../CouponManageRepositoryTest.java | 172 ++++++++++++++ .../repository/CouponQueueRepositoryTest.java | 63 ----- .../CouponWalletSearchRepositoryTest.java | 2 +- .../redis/StringRedisRepositoryTest.java | 47 ++-- .../redis/ZSetRedisRepositoryTest.java | 69 ++++-- .../presentation/CouponControllerTest.java | 91 ++++++-- .../moabam/support/fixture/CouponFixture.java | 28 ++- 29 files changed, 797 insertions(+), 395 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/coupon/CouponManageService.java delete mode 100644 src/main/java/com/moabam/api/application/coupon/CouponQueueService.java create mode 100644 src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java delete mode 100644 src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java create mode 100644 src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java delete mode 100644 src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java create mode 100644 src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java delete mode 100644 src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index de07033f..46908ae4 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -55,7 +55,7 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, Payment payment = PaymentMapper.toPayment(memberId, product); if (!isNull(request.couponWalletId())) { - Coupon coupon = couponService.getByWallet(request.couponWalletId(), memberId); + Coupon coupon = couponService.getByWalletIdAndMemberId(request.couponWalletId(), memberId); payment.applyCoupon(coupon, request.couponWalletId()); } paymentRepository.save(payment); 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..3fd13b1b --- /dev/null +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -0,0 +1,91 @@ +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.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.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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Service +@RequiredArgsConstructor +public class CouponManageService { + + private static final long ISSUE_SIZE = 10; + + private final ClockHolder clockHolder; + + private final CouponRepository couponRepository; + private final CouponManageRepository couponManageRepository; + private final CouponWalletRepository couponWalletRepository; + + @Scheduled(fixedDelay = 1000) + public void issue() { + LocalDate now = LocalDate.from(clockHolder.times()); + Optional isCoupon = couponRepository.findByStartAt(now); + + if (!canIssue(isCoupon)) { + return; + } + + Coupon coupon = isCoupon.get(); + Set membersId = couponManageRepository.popMinQueue(coupon.getName(), ISSUE_SIZE); + + membersId.forEach(memberId -> { + int nextStock = couponManageRepository.increaseIssuedStock(coupon.getName()); + + if (coupon.getStock() < nextStock) { + return; + } + + CouponWallet couponWallet = CouponWallet.create(memberId, coupon); + couponWalletRepository.save(couponWallet); + }); + } + + public void register(AuthMember authMember, String couponName) { + double registerTime = System.currentTimeMillis(); + validateRegister(couponName); + couponManageRepository.addIfAbsentQueue(couponName, authMember.id(), registerTime); + } + + public void deleteCouponManage(String couponName) { + couponManageRepository.deleteQueue(couponName); + couponManageRepository.deleteIssuedStock(couponName); + } + + private void validateRegister(String couponName) { + LocalDate now = LocalDate.from(clockHolder.times()); + Optional coupon = couponRepository.findByStartAt(now); + + if (coupon.isEmpty() || !coupon.get().getName().equals(couponName)) { + throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); + } + } + + private boolean canIssue(Optional coupon) { + if (coupon.isEmpty()) { + return false; + } + + Coupon currentCoupon = coupon.get(); + int currentStock = couponManageRepository.getIssuedStock(currentCoupon.getName()); + int maxStock = currentCoupon.getStock(); + + return currentStock < maxStock; + } +} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java b/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java deleted file mode 100644 index 2d0c62e9..00000000 --- a/src/main/java/com/moabam/api/application/coupon/CouponQueueService.java +++ /dev/null @@ -1,36 +0,0 @@ -package com.moabam.api.application.coupon; - -import org.springframework.stereotype.Service; - -import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.coupon.repository.CouponQueueRepository; -import com.moabam.global.auth.model.AuthMember; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CouponQueueService { - - private final CouponService couponService; - private final CouponQueueRepository couponQueueRepository; - - public void register(AuthMember authMember, String couponName) { - double registerTime = System.currentTimeMillis(); - - if (canRegister(couponName)) { - log.info("{} 쿠폰이 모두 발급되었습니다.", couponName); - return; - } - - couponQueueRepository.addIfAbsent(couponName, authMember.nickname(), registerTime); - } - - private boolean canRegister(String couponName) { - Coupon coupon = couponService.validatePeriod(couponName); - - return coupon.getStock() <= couponQueueRepository.size(coupon.getName()); - } -} diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index cdc6fa44..b8310e7d 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -28,15 +28,18 @@ @Transactional(readOnly = true) public class CouponService { + private final ClockHolder clockHolder; + private final CouponManageService couponManageService; + private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; private final CouponWalletSearchRepository couponWalletSearchRepository; - private final ClockHolder clockHolder; @Transactional public void create(AuthMember admin, CreateCouponRequest request) { validateAdminRole(admin); validateConflictName(request.name()); + validateConflictStartAt(request.startAt()); validatePeriod(request.startAt(), request.openAt()); Coupon coupon = CouponMapper.toEntity(admin.id(), request); @@ -49,6 +52,7 @@ public void delete(AuthMember admin, Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); couponRepository.delete(coupon); + couponManageService.deleteCouponManage(coupon.getName()); } public CouponResponse getById(Long couponId) { @@ -58,7 +62,7 @@ public CouponResponse getById(Long couponId) { return CouponMapper.toDto(coupon); } - public Coupon getByWallet(Long couponWalletId, Long memberId) { + public Coupon getByWalletIdAndMemberId(Long couponWalletId, Long memberId) { return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)) .getCoupon(); @@ -73,18 +77,6 @@ public List getAllByStatus(CouponStatusRequest request) { .toList(); } - public Coupon validatePeriod(String couponName) { - LocalDate now = LocalDate.from(clockHolder.times()); - Coupon coupon = couponRepository.findByName(couponName) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); - - if (!now.equals(coupon.getStartAt())) { - throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); - } - - return coupon; - } - private void validatePeriod(LocalDate startAt, LocalDate openAt) { LocalDate now = LocalDate.from(clockHolder.times()); @@ -108,4 +100,10 @@ private void validateConflictName(String 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/domain/coupon/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java index daeda469..e539d1b0 100644 --- a/src/main/java/com/moabam/api/domain/coupon/Coupon.java +++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java @@ -55,7 +55,7 @@ public class Coupon extends BaseTimeEntity { @Column(name = "stock", nullable = false) private int stock; - @Column(name = "start_at", nullable = false) + @Column(name = "start_at", unique = true, nullable = false) private LocalDate startAt; @Column(name = "open_at", nullable = false) diff --git a/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java b/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java index da0ff343..80994081 100644 --- a/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java +++ b/src/main/java/com/moabam/api/domain/coupon/CouponWallet.java @@ -12,7 +12,6 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -34,9 +33,12 @@ public class CouponWallet extends BaseTimeEntity { @ManyToOne(fetch = FetchType.LAZY) private Coupon coupon; - @Builder 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..b2aa3814 --- /dev/null +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -0,0 +1,63 @@ +package com.moabam.api.domain.coupon.repository; + +import static java.util.Objects.*; + +import java.util.Set; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Repository; + +import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +import lombok.RequiredArgsConstructor; + +@Repository +@RequiredArgsConstructor +public class CouponManageRepository { + + private static final String STOCK_KEY = "%s_INCR"; + + private final ZSetRedisRepository zSetRedisRepository; + private final StringRedisRepository stringRedisRepository; + + public void addIfAbsentQueue(String couponName, Long memberId, double registerTime) { + zSetRedisRepository.addIfAbsent(requireNonNull(couponName), requireNonNull(memberId), registerTime); + } + + public Set popMinQueue(String couponName, long count) { + return zSetRedisRepository + .popMin(requireNonNull(couponName), count) + .stream() + .map(tuple -> (Long)tuple.getValue()) + .collect(Collectors.toSet()); + } + + public void deleteQueue(String couponName) { + stringRedisRepository.delete(requireNonNull(couponName)); + } + + public int increaseIssuedStock(String couponName) { + String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); + + return stringRedisRepository + .increment(requireNonNull(stockKey)) + .intValue(); + } + + public int getIssuedStock(String couponName) { + String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); + String stockValue = stringRedisRepository.get(requireNonNull(stockKey)); + + if (stockValue == null) { + return 0; + } + + return Integer.parseInt(stockValue); + } + + public void deleteIssuedStock(String couponName) { + String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); + stringRedisRepository.delete(requireNonNull(stockKey)); + } +} diff --git a/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java b/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java deleted file mode 100644 index a7ab35ef..00000000 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponQueueRepository.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.moabam.api.domain.coupon.repository; - -import static java.util.Objects.*; - -import org.springframework.stereotype.Repository; - -import com.moabam.api.infrastructure.redis.ZSetRedisRepository; - -import lombok.RequiredArgsConstructor; - -@Repository -@RequiredArgsConstructor -public class CouponQueueRepository { - - private final ZSetRedisRepository zSetRedisRepository; - - public void addIfAbsent(String couponName, String memberNickname, double score) { - zSetRedisRepository.addIfAbsent(requireNonNull(couponName), requireNonNull(memberNickname), score); - } - - public Long size(String couponName) { - return zSetRedisRepository.size(requireNonNull(couponName)); - } -} 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 index 02760025..38b9dbe1 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java @@ -1,5 +1,6 @@ package com.moabam.api.domain.coupon.repository; +import java.time.LocalDate; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -8,7 +9,9 @@ public interface CouponRepository extends JpaRepository { - Optional findByName(String couponName); - boolean existsByName(String name); + + boolean existsByStartAt(LocalDate startAt); + + Optional findByStartAt(LocalDate startAt); } diff --git a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java index ce1f067b..c5d4d9df 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java @@ -19,8 +19,10 @@ public void save(String key, String value, Duration timeout) { .set(key, value, timeout); } - public void delete(String key) { - redisTemplate.delete(key); + public Long increment(String key) { + return redisTemplate + .opsForValue() + .increment(key); } public String get(String key) { @@ -32,4 +34,8 @@ public String get(String 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 index e02a3c4e..9ac7c607 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -1,6 +1,11 @@ package com.moabam.api.infrastructure.redis; +import static java.util.Objects.*; + +import java.util.Set; + import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import org.springframework.stereotype.Repository; import lombok.RequiredArgsConstructor; @@ -11,25 +16,17 @@ public class ZSetRedisRepository { private final RedisTemplate redisTemplate; - public void addIfAbsent(String key, String value, double score) { + public void addIfAbsent(String key, Object value, double score) { if (redisTemplate.opsForZSet().score(key, value) == null) { redisTemplate .opsForZSet() - .add(key, value, score); + .add(requireNonNull(key), requireNonNull(value), score); } } - public Long size(String key) { + public Set> popMin(String key, long count) { return redisTemplate .opsForZSet() - .size(key); - } - - public Boolean hasKey(String key) { - return redisTemplate.hasKey(key); - } - - public void delete(String key) { - redisTemplate.delete(key); + .popMin(key, count); } } diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index 41d817ae..e5b03746 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -12,7 +12,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import com.moabam.api.application.coupon.CouponQueueService; +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; @@ -28,7 +28,7 @@ public class CouponController { private final CouponService couponService; - private final CouponQueueService couponQueueService; + private final CouponManageService couponManageService; @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) @@ -55,7 +55,7 @@ public List getAllByStatus(@Valid @RequestBody CouponStatusReque } @PostMapping("/coupons") - public void registerCouponQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { - couponQueueService.register(authMember, couponName); + public void registerQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { + couponManageService.register(authMember, couponName); } } diff --git a/src/main/java/com/moabam/global/common/util/DynamicQuery.java b/src/main/java/com/moabam/global/common/util/DynamicQuery.java index c3ea6069..47468ee9 100644 --- a/src/main/java/com/moabam/global/common/util/DynamicQuery.java +++ b/src/main/java/com/moabam/global/common/util/DynamicQuery.java @@ -1,12 +1,8 @@ package com.moabam.global.common.util; -import java.util.List; import java.util.Objects; -import java.util.Optional; import java.util.function.Function; -import org.springframework.util.CollectionUtils; - import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.SimpleExpression; @@ -35,16 +31,4 @@ public static BooleanExpression generateIsNull(Bool return field.isNotNull(); } - - public static BooleanExpression filterCondition(T condition, Function function) { - T tempCondition = condition; - - if (tempCondition instanceof List c && CollectionUtils.isEmpty(c)) { - tempCondition = null; - } - - return Optional.ofNullable(tempCondition) - .map(function) - .orElse(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 index ddc96335..0fc50530 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -70,6 +70,7 @@ public enum ErrorMessage { INVALID_COUPON_OPEN_AT_PERIOD("쿠폰 정보 오픈 날짜는 시작 날짜보다 이전이여야 합니다."), INVALID_COUPON_PERIOD("쿠폰 발급 가능 기간이 아닙니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), + CONFLICT_COUPON_START_AT("쿠폰 발급 가능 날짜가 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), NOT_FOUND_COUPON_WALLET("보유하지 않은 쿠폰입니다."), diff --git a/src/main/resources/config b/src/main/resources/config index 2e460460..2a1a59a1 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 +Subproject commit 2a1a59a16d8e868185c125a58aec0682f3c53f0d diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index d31fe33a..5678c54e 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -537,7 +537,7 @@

응답

Content-Length: 215 { - "id" : 26, + "id" : 16, "adminName" : "1admin", "name" : "couponName", "description" : "", @@ -585,7 +585,7 @@

응답

Content-Length: 216 [ { - "id" : 15, + "id" : 17, "adminName" : "1admin", "name" : "coupon1", "description" : "", @@ -627,7 +627,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "쿠폰 발급 가능 기간이 아닙니다." diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 38ed7f63..3340a231 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -473,7 +473,7 @@

응답

Vary: Access-Control-Request-Method Vary: Access-Control-Request-Headers Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "해당 유저는 접속 중이 아닙니다." diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index 0d4bce1f..1cde9be1 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -103,7 +103,7 @@ void apply_coupon_success() { PurchaseProductRequest request = new PurchaseProductRequest(couponWalletId); given(productRepository.findById(productId)).willReturn(Optional.of(bugProduct())); given(paymentRepository.save(any(Payment.class))).willReturn(payment); - given(couponService.getByWallet(couponWalletId, memberId)).willReturn(discount1000Coupon()); + given(couponService.getByWalletIdAndMemberId(couponWalletId, memberId)).willReturn(discount1000Coupon()); // when PurchaseProductResponse response = bugService.purchaseBugProduct(memberId, productId, request); 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..214ca0ab --- /dev/null +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -0,0 +1,215 @@ +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.time.LocalDateTime; +import java.util.HashSet; +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.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +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.auth.model.AuthMember; +import com.moabam.global.auth.model.AuthorizationThreadLocal; +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.CouponFixture; + +@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) +class CouponManageServiceTest { + + @InjectMocks + CouponManageService couponManageService; + + @Mock + CouponRepository couponRepository; + + @Mock + CouponManageRepository couponManageRepository; + + @Mock + CouponWalletRepository couponWalletRepository; + + @Mock + ClockHolder clockHolder; + + @DisplayName("쿠폰 발행이 성공적으로 된다.") + @Test + void issue_all_success() { + // Given + Coupon coupon = CouponFixture.coupon(1000, 100); + Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); + given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); + given(couponManageRepository.increaseIssuedStock(any(String.class))).willReturn(99); + + // When + couponManageService.issue(); + + // Then + verify(couponWalletRepository, times(10)).save(any(CouponWallet.class)); + } + + @DisplayName("발행 가능한 쿠폰이 없다.") + @Test + void issue_notStartAt() { + // Given + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); + + // When + couponManageService.issue(); + + // Then + verify(couponManageRepository, times(0)).getIssuedStock(any(String.class)); + verify(couponManageRepository, times(0)).popMinQueue(any(String.class), any(long.class)); + verify(couponManageRepository, times(0)).increaseIssuedStock(any(String.class)); + verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + } + + @DisplayName("해당 쿠폰은 재고가 마감된 쿠폰이다.") + @Test + void issue_stockEnd() { + // Given + Coupon coupon = CouponFixture.coupon(1000, 100); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(coupon.getStock()); + + // When + couponManageService.issue(); + + // Then + verify(couponManageRepository, times(0)).popMinQueue(any(String.class), any(long.class)); + verify(couponManageRepository, times(0)).increaseIssuedStock(any(String.class)); + verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + } + + @DisplayName("대기열에 남은 인원이 모두 발급받지 못한다.") + @Test + void issue_queue_stockENd() { + // Given + Coupon coupon = CouponFixture.coupon(1000, 100); + Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); + given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); + given(couponManageRepository.increaseIssuedStock(any(String.class))).willReturn(101); + + // When + couponManageService.issue(); + + // Then + verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + } + + @WithMember + @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") + @Test + void register_success() { + // Given + AuthMember member = AuthorizationThreadLocal.getAuthMember(); + Coupon coupon = CouponFixture.coupon(); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + + // When + couponManageService.register(member, coupon.getName()); + + // Then + verify(couponManageRepository).addIfAbsentQueue(any(String.class), any(Long.class), any(double.class)); + } + + @WithMember + @DisplayName("금일 발급이 가능한 쿠폰이 없다. - BadRequestException") + @Test + void register_StartAt_BadRequestException() { + // Given + AuthMember member = AuthorizationThreadLocal.getAuthMember(); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> couponManageService.register(member, "couponName")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); + } + + @WithMember + @DisplayName("금일 발급 가능한 쿠폰의 이름과 일치하지 않는다. - BadRequestException") + @Test + void register_Name_BadRequestException() { + // Given + AuthMember member = AuthorizationThreadLocal.getAuthMember(); + Coupon coupon = CouponFixture.coupon(); + + given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + + // When & Then + assertThatThrownBy(() -> couponManageService.register(member, "Coupon Cannot Be Issued Today")) + .isInstanceOf(BadRequestException.class) + .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); + } + + @DisplayName("쿠폰 대기열과 발행된 재고가 정상적으로 삭제된다.") + @Test + void deleteCouponManage_success() { + // Given + String couponName = "couponName"; + + // When + couponManageService.deleteCouponManage(couponName); + + // Then + verify(couponManageRepository).deleteQueue(couponName); + verify(couponManageRepository).deleteIssuedStock(couponName); + } + + @DisplayName("쿠폰 대기열이 정상적으로 삭제되지 않는다.") + @Test + void deleteCouponManage_Queue_NullPointerException() { + // Given + willThrow(NullPointerException.class).given(couponManageRepository).deleteQueue(any(String.class)); + + // When & Then + assertThatThrownBy(() -> couponManageService.deleteCouponManage("null")) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰의 발행된 재고가 정상적으로 삭제되지 않는다.") + @Test + void deleteCouponManage_Stock_NullPointerException() { + // Given + willDoNothing().given(couponManageRepository).deleteQueue(any(String.class)); + willThrow(NullPointerException.class).given(couponManageRepository).deleteIssuedStock(any(String.class)); + + // When & Then + assertThatThrownBy(() -> couponManageService.deleteCouponManage("null")) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java deleted file mode 100644 index b6721d68..00000000 --- a/src/test/java/com/moabam/api/application/coupon/CouponQueueServiceTest.java +++ /dev/null @@ -1,83 +0,0 @@ -package com.moabam.api.application.coupon; - -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.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import com.moabam.api.domain.coupon.Coupon; -import com.moabam.api.domain.coupon.repository.CouponQueueRepository; -import com.moabam.global.auth.model.AuthMember; -import com.moabam.global.auth.model.AuthorizationThreadLocal; -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.CouponFixture; - -@ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) -class CouponQueueServiceTest { - - @InjectMocks - private CouponQueueService couponQueueService; - - @Mock - private CouponQueueRepository couponQueueRepository; - - @Mock - private CouponService couponService; - - @WithMember - @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") - @Test - void register() { - // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - Coupon coupon = CouponFixture.coupon(); - - given(couponService.validatePeriod(any(String.class))).willReturn(coupon); - given(couponQueueRepository.size(any(String.class))).willReturn(coupon.getStock() - 1L); - - // When - couponQueueService.register(member, coupon.getName()); - - // Then - verify(couponQueueRepository).addIfAbsent(any(String.class), any(String.class), any(double.class)); - } - - @WithMember - @DisplayName("해당 쿠폰은 발급 가능 기간이 아니다. - BadRequestException") - @Test - void register_BadRequestException() { - // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - - given(couponService.validatePeriod(any(String.class))) - .willThrow(new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD)); - - // When & Then - assertThatThrownBy(() -> couponQueueService.register(member, "couponName")) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); - } - - @WithMember - @DisplayName("해당 쿠폰은 마감된 쿠폰이다. - Void") - @Test - void register_End() { - // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - Coupon coupon = CouponFixture.coupon(); - - given(couponService.validatePeriod(any(String.class))).willReturn(coupon); - given(couponQueueRepository.size(any(String.class))).willReturn((long)coupon.getStock()); - - // When & Then - assertThatNoException().isThrownBy(() -> couponQueueService.register(member, coupon.getName())); - } -} diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 18099a8c..d929f46b 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -40,21 +40,24 @@ class CouponServiceTest { @InjectMocks - private CouponService couponService; + CouponService couponService; @Mock - private CouponRepository couponRepository; + CouponManageService couponManageService; @Mock - private CouponSearchRepository couponSearchRepository; + CouponRepository couponRepository; @Mock - private ClockHolder clockHolder; + CouponSearchRepository couponSearchRepository; + + @Mock + ClockHolder clockHolder; @WithMember(role = Role.ADMIN) @DisplayName("쿠폰을 성공적으로 발행한다. - Void") @Test - void create() { + void create_success() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); @@ -103,7 +106,7 @@ void create_Type_NotFoundException() { @WithMember(role = Role.ADMIN) @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") @Test - void create_ConflictException() { + void create_Name_ConflictException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); @@ -116,6 +119,23 @@ void create_ConflictException() { .hasMessage(ErrorMessage.CONFLICT_COUPON_NAME.getMessage()); } + @WithMember(role = Role.ADMIN) + @DisplayName("중복된 쿠폰 발행 가능 날짜를 발행한다. - ConflictException") + @Test + void create_StartAt_ConflictException() { + // Given + AuthMember admin = AuthorizationThreadLocal.getAuthMember(); + 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(admin, request)) + .isInstanceOf(ConflictException.class) + .hasMessage(ErrorMessage.CONFLICT_COUPON_START_AT.getMessage()); + } + @WithMember(role = Role.ADMIN) @DisplayName("현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") @Test @@ -126,6 +146,7 @@ void create_StartAt_BadRequestException() { given(clockHolder.times()).willReturn(LocalDateTime.of(2025, 1, 1, 1, 1)); given(couponRepository.existsByName(any(String.class))).willReturn(false); + given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(false); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) @@ -143,6 +164,7 @@ void create_OpenAt_BadRequestException() { 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.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When & Then @@ -152,9 +174,9 @@ void create_OpenAt_BadRequestException() { } @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 삭제한다. - Void") + @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 성공적으로 삭제한다. - Void") @Test - void delete() { + void delete_success() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(10, 100); @@ -165,6 +187,7 @@ void delete() { // Then verify(couponRepository).delete(coupon); + verify(couponManageService).deleteCouponManage(any(String.class)); } @WithMember(role = Role.USER) @@ -194,9 +217,9 @@ void delete_NotFoundException() { .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); } - @DisplayName("특정 쿠폰을 조회한다. - CouponResponse") + @DisplayName("특정 쿠폰을 성공적으로 조회한다. - CouponResponse") @Test - void getById() { + void getById_success() { // Given Coupon coupon = CouponFixture.coupon(10, 100); given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); @@ -221,10 +244,10 @@ void getById_NotFoundException() { .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); } - @DisplayName("모든 쿠폰을 조회한다. - List") + @DisplayName("모든 쿠폰을 성공적으로 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void getAllByStatus(List coupons) { + void getAllByStatus_success(List coupons) { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); @@ -238,49 +261,4 @@ void getAllByStatus(List coupons) { // Then assertThat(actual).hasSize(coupons.size()); } - - @DisplayName("해당 쿠폰은 발급 가능 기간입니다. - Coupon") - @Test - void validatePeriod() { - // Given - LocalDateTime now = LocalDateTime.of(2023, 1, 1, 1, 0); - Coupon coupon = CouponFixture.coupon("couponName", 1, 2); - given(couponRepository.findByName(any(String.class))).willReturn(Optional.of(coupon)); - given(clockHolder.times()).willReturn(now); - - // When - Coupon actual = couponService.validatePeriod(coupon.getName()); - - // Then - assertThat(actual.getName()).isEqualTo(coupon.getName()); - } - - @DisplayName("해당 쿠폰은 발급 가능 기간이 아닙니다. - BadRequestException") - @Test - void validatePeriod_BadRequestException() { - // Given - LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); - Coupon coupon = CouponFixture.coupon("couponName", 1, 2); - given(couponRepository.findByName(any(String.class))).willReturn(Optional.of(coupon)); - given(clockHolder.times()).willReturn(now); - - // When & Then - assertThatThrownBy(() -> couponService.validatePeriod("couponName")) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); - } - - @DisplayName("해당 쿠폰은 존재하지 않습니다. - NotFoundException") - @Test - void validatePeriod_NotFoundException() { - // Given - LocalDateTime now = LocalDateTime.of(2022, 1, 1, 1, 0); - given(couponRepository.findByName(any(String.class))).willReturn(Optional.empty()); - given(clockHolder.times()).willReturn(now); - - // When & Then - assertThatThrownBy(() -> couponService.validatePeriod("Not found coupon name")) - .isInstanceOf(NotFoundException.class) - .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); - } } diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java index d1b7ee46..059a9838 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java @@ -16,10 +16,7 @@ void couponWallet() { Coupon coupon = CouponFixture.coupon("CouponName", 1, 2); // When - CouponWallet actual = CouponWallet.builder() - .memberId(1L) - .coupon(coupon) - .build(); + CouponWallet actual = CouponWallet.create(1L, coupon); // Then assertThat(actual.getMemberId()).isEqualTo(1L); 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..03c7cf49 --- /dev/null +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java @@ -0,0 +1,172 @@ +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 org.springframework.data.redis.core.ZSetOperations.TypedTuple; + +import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ZSetRedisRepository; + +@ExtendWith(MockitoExtension.class) +class CouponManageRepositoryTest { + + @InjectMocks + CouponManageRepository couponManageRepository; + + @Mock + ZSetRedisRepository zSetRedisRepository; + + @Mock + StringRedisRepository stringRedisRepository; + + @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)); + } + + @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명을 꺼내고 삭제한다.") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideTypedTuples") + @ParameterizedTest + void popMinQueue_success(Set> tuples) { + // Given + given(zSetRedisRepository.popMin(any(String.class), any(long.class))).willReturn(tuples); + + // When + Set actual = couponManageRepository.popMinQueue("couponName", 10); + + // Then + assertThat(actual).hasSize(10); + } + + @DisplayName("쿠폰명이 Null인 대기열에서 사용자를 꺼낸다. - NullPointerException") + @Test + void popMinQueue_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.popMinQueue(null, 10)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰 대기열을 성공적으로 삭제한다. - Void") + @Test + void deleteQueue_success() { + // When + couponManageRepository.deleteQueue("couponName"); + + // Then + verify(stringRedisRepository).delete(any(String.class)); + } + + @DisplayName("쿠폰명이 Null인 대기열을 삭제한다. - NullPointerException") + @Test + void deleteQueue_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.deleteQueue(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰의 할당된 재고를 성공적으로 증가시킨다. - int") + @Test + void increaseIssuedStock_success() { + // Given + given(stringRedisRepository.increment(any(String.class))).willReturn(77L); + + // When + int actual = couponManageRepository.increaseIssuedStock("couponName"); + + // Then + assertThat(actual).isEqualTo(77); + } + + @DisplayName("쿠폰명이 Null인 쿠폰의 할당된 재고를 증가시킨다. - NullPointerException") + @Test + void increaseIssuedStock_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.increaseIssuedStock(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("쿠폰의 현재 할당된 재고를 성공적으로 조회한다. - int") + @Test + void getIssuedStock_success() { + // Given + given(stringRedisRepository.get(any(String.class))).willReturn("1"); + + // When + int actual = couponManageRepository.getIssuedStock("couponName"); + + // Then + assertThat(actual).isEqualTo(1); + } + + @DisplayName("쿠폰의 현재 할당된 재고가 없어서 0이 조회된다. - int") + @Test + void getIssuedStock_zero() { + // Given + given(stringRedisRepository.get(any(String.class))).willReturn(null); + + // When + int actual = couponManageRepository.getIssuedStock("couponName"); + + // Then + assertThat(actual).isZero(); + } + + @DisplayName("쿠폰명이 Null인 쿠폰의 할당된 재고를 조회한다. - NullPointerException") + @Test + void getIssuedStock_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.getIssuedStock(null)) + .isInstanceOf(NullPointerException.class); + } + + @DisplayName("할당된 쿠폰 재고를 성공적으로 삭제한다. - Void") + @Test + void deleteIssuedStock_success() { + // When + couponManageRepository.deleteIssuedStock("couponName"); + + // Then + verify(stringRedisRepository).delete(any(String.class)); + } + + @DisplayName("쿠폰명이 Null인 할당된 쿠폰 재고를 삭제한다. - NullPointerException") + @Test + void deleteIssuedStock_NullPointerException() { + // When & Then + assertThatThrownBy(() -> couponManageRepository.deleteIssuedStock(null)) + .isInstanceOf(NullPointerException.class); + } +} diff --git a/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java deleted file mode 100644 index 9c48c266..00000000 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponQueueRepositoryTest.java +++ /dev/null @@ -1,63 +0,0 @@ -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 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.ZSetRedisRepository; - -@ExtendWith(MockitoExtension.class) -class CouponQueueRepositoryTest { - - @InjectMocks - private CouponQueueRepository couponQueueRepository; - - @Mock - private ZSetRedisRepository zSetRedisRepository; - - @DisplayName("특정 쿠폰의 대기열에 사용자가 성공적으로 등록된다. - Void") - @Test - void addQueue() { - // When - couponQueueRepository.addIfAbsent("couponName", "memberNickname", 1); - - // Then - verify(zSetRedisRepository).addIfAbsent(any(String.class), any(String.class), any(Double.class)); - } - - @DisplayName("특정 쿠폰의 대기열에 사용자 등록 시, 필요한 값이 NULL 이다.- NullPointerException") - @Test - void addQueue_NullPointerException() { - // When & Then - assertThatThrownBy(() -> couponQueueRepository.addIfAbsent(null, "value", 1)) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("특정 쿠폰을 발급한 사용자가 3명이다. - Long") - @Test - void queueSize() { - // Given - given(zSetRedisRepository.size(any(String.class))).willReturn(3L); - - // When - long actual = couponQueueRepository.size("key"); - - // Then - assertThat(actual).isEqualTo(3); - } - - @DisplayName("특정 쿠폰을 발급한 사용자 수 조회 시, 필요한 값이 Null이다. - NullPointerException") - @Test - void queueSize_NullPointerException() { - // When & Then - assertThatThrownBy(() -> couponQueueRepository.size(null)) - .isInstanceOf(NullPointerException.class); - } -} 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 index f1066346..db5176c7 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -30,7 +30,7 @@ void find_by_id_and_member_id() { Long id = 1L; Long memberId = 1L; Coupon coupon = couponRepository.save(discount1000Coupon()); - couponWalletRepository.save(couponWallet(memberId, coupon)); + couponWalletRepository.save(CouponWallet.create(memberId, coupon)); // when CouponWallet actual = couponWalletSearchRepository.findByIdAndMemberId(id, memberId).orElseThrow(); diff --git a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java index e38fbe34..83b9d44a 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java @@ -17,10 +17,11 @@ class StringRedisRepositoryTest { @Autowired - private StringRedisRepository stringRedisRepository; + StringRedisRepository stringRedisRepository; String key = "key"; String value = "value"; + String stockKey = "key_INCR"; Duration duration = Duration.ofMillis(5000); @BeforeEach @@ -30,29 +31,25 @@ void setUp() { @AfterEach void setDown() { - stringRedisRepository.delete(key); + if (stringRedisRepository.hasKey(key)) { + stringRedisRepository.delete(key); + } + + if (stringRedisRepository.hasKey(stockKey)) { + stringRedisRepository.delete(stockKey); + } } @DisplayName("레디스에 문자열 데이터가 성공적으로 저장된다. - Void") @Test - void stringRedisRepository_save() { + void save_success() { // Then assertThat(stringRedisRepository.get(key)).isEqualTo(value); } - @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") - @Test - void stringRedisRepository_delete() { - // When - stringRedisRepository.delete(key); - - // Then - assertThat(stringRedisRepository.hasKey(key)).isFalse(); - } - @DisplayName("레디스의 특정 데이터가 성공적으로 조회된다. - String(Value)") @Test - void stringRedisRepository_get() { + void get_success() { // When String actual = stringRedisRepository.get(key); @@ -62,8 +59,28 @@ void stringRedisRepository_get() { @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") @Test - void stringRedisRepository_hasKey() { + void hasKey_success() { // When & Then assertThat(stringRedisRepository.hasKey("not found key")).isFalse(); } + + @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") + @Test + void delete_success() { + // When + stringRedisRepository.delete(key); + + // Then + assertThat(stringRedisRepository.hasKey(key)).isFalse(); + } + + @DisplayName("레디스의 특정 데이터의 값이 1 증가한다.") + @Test + void increment_success() { + // When + Long actual = stringRedisRepository.increment(stockKey); + + // 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 index 0713fdff..5b94879d 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java @@ -2,42 +2,49 @@ 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 org.springframework.data.redis.core.ZSetOperations.TypedTuple; import com.moabam.global.config.EmbeddedRedisConfig; -@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class}) +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class, StringRedisRepository.class}) class ZSetRedisRepositoryTest { @Autowired - private ZSetRedisRepository zSetRedisRepository; + ZSetRedisRepository zSetRedisRepository; + + @Autowired + StringRedisRepository stringRedisRepository; @Autowired - private RedisTemplate redisTemplate; + RedisTemplate redisTemplate; String key = "key"; - String value = "value"; + Long value = 1L; @AfterEach void afterEach() { - if (zSetRedisRepository.hasKey(key)) { - zSetRedisRepository.delete(key); + if (stringRedisRepository.hasKey(key)) { + stringRedisRepository.delete(key); } } @DisplayName("레디스의 SortedSet 데이터가 성공적으로 저장된다. - Void") @Test - void setRedisRepository_addIfAbsent() { + void addIfAbsent_success() { // When zSetRedisRepository.addIfAbsent(key, value, 1); // Then - assertThat(zSetRedisRepository.size(key)).isEqualTo(1); + assertThat(stringRedisRepository.hasKey(key)).isTrue(); } @DisplayName("이미 존재하는 값을 한 번 더 저장을 시도한다. - Void") @@ -51,36 +58,54 @@ void setRedisRepository_addIfAbsent_not_update() { assertThat(redisTemplate.opsForZSet().score(key, value)).isEqualTo(1); } - @DisplayName("레디스의 특정 키의 사이즈가 성공적으로 반환된다. - int") + @Disabled + @DisplayName("저장된 데이터와 동일한 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") @Test - void setRedisRepository_size() { + void popMin_same_success() { // Given - zSetRedisRepository.addIfAbsent(key, value, 1); + zSetRedisRepository.addIfAbsent(key, value + 1, 1); + zSetRedisRepository.addIfAbsent(key, value + 2, 2); + zSetRedisRepository.addIfAbsent(key, value + 3, 3); // When - long actual = zSetRedisRepository.size(key); + Set> actual = zSetRedisRepository.popMin(key, 3); // Then - assertThat(actual).isEqualTo(1); + assertThat(actual).hasSize(3); + assertThat(stringRedisRepository.hasKey(key)).isFalse(); } - @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") + @Disabled + @DisplayName("저장된 데이터보다 많은 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") @Test - void setRedisRepository_delete() { + void popMin_more_success() { // Given - zSetRedisRepository.addIfAbsent(key, value, 1); + zSetRedisRepository.addIfAbsent(key, value + 1, 1); + zSetRedisRepository.addIfAbsent(key, value + 2, 2); // When - zSetRedisRepository.delete(key); + Set> actual = zSetRedisRepository.popMin(key, 3); // Then - assertThat(zSetRedisRepository.hasKey(key)).isFalse(); + assertThat(actual).hasSize(2); } - @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") + @Disabled + @DisplayName("저장된 데이터보다 더 적은 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") @Test - void setRedisRepository_hasKey() { - // When & Then - assertThat(zSetRedisRepository.hasKey("not found key")).isFalse(); + void popMin_less_success() { + // Given + zSetRedisRepository.addIfAbsent(key, value + 1, 1); + zSetRedisRepository.addIfAbsent(key, value + 2, 2); + zSetRedisRepository.addIfAbsent(key, value + 3, 3); + zSetRedisRepository.addIfAbsent(key, value + 4, 4); + zSetRedisRepository.addIfAbsent(key, value + 5, 5); + + // When + Set> actual = zSetRedisRepository.popMin(key, 3); + + // Then + assertThat(actual).hasSize(3); + assertThat(stringRedisRepository.hasKey(key)).isTrue(); } } diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 87173012..d9fa0bfa 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -47,21 +47,21 @@ class CouponControllerTest extends WithoutFilterSupporter { @Autowired - private MockMvc mockMvc; + MockMvc mockMvc; @Autowired - private ObjectMapper objectMapper; + ObjectMapper objectMapper; @Autowired - private CouponRepository couponRepository; + CouponRepository couponRepository; @MockBean - private ClockHolder clockHolder; + ClockHolder clockHolder; @WithMember(role = Role.ADMIN) @DisplayName("POST - 쿠폰을 성공적으로 발행한다. - Void") @Test - void create_Coupon() throws Exception { + void create_Coupon_success() throws Exception { // Given CreateCouponRequest request = CouponFixture.createCouponRequest(); given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); @@ -130,7 +130,7 @@ void create_Coupon_OpenAt_BadRequestException() throws Exception { @WithMember(role = Role.ADMIN) @DisplayName("POST - 쿠폰명이 중복된 쿠폰을 발행한다. - ConflictException") @Test - void create_Coupon_ConflictException() throws Exception { + void create_Coupon_Name_ConflictException() throws Exception { // Given CreateCouponRequest request = CouponFixture.createCouponRequest(); couponRepository.save(CouponMapper.toEntity(1L, request)); @@ -150,10 +150,34 @@ void create_Coupon_ConflictException() throws Exception { .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()), + CouponSnippetFixture.CREATE_COUPON_REQUEST, + ErrorSnippetFixture.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() throws Exception { + void delete_Coupon_success() throws Exception { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); @@ -182,9 +206,9 @@ void delete_Coupon_NotFoundException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); } - @DisplayName("GET - 특정 쿠폰을 조회한다. - CouponResponse") + @DisplayName("GET - 특정 쿠폰을 성공적으로 조회한다. - CouponResponse") @Test - void getById_Coupon() throws Exception { + void getById_Coupon_success() throws Exception { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon(10, 100)); @@ -218,7 +242,7 @@ void getById_Coupon_NotFoundException() throws Exception { @DisplayName("POST - 모든 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void getAllByStatus_Coupons(List coupons) throws Exception { + void getAllByStatus_Coupons_success(List coupons) throws Exception { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); List coupon = couponRepository.saveAll(coupons); @@ -240,10 +264,10 @@ void getAllByStatus_Coupons(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(coupon.size()))); } - @DisplayName("POST - 발급 가능한 쿠폰만 조회한다.. - List") + @DisplayName("POST - 발급 가능한 쿠폰만 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void getAllByStatus_Coupon(List coupons) throws Exception { + void getAllByStatus_Coupon_success(List coupons) throws Exception { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); couponRepository.saveAll(coupons); @@ -264,10 +288,10 @@ void getAllByStatus_Coupon(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(1))); } - @WithMember(nickname = "member-coupon-1") - @DisplayName("POST - 쿠폰 발급 요청을 한다. - Void") + @WithMember + @DisplayName("POST - 쿠폰 발급 요청을 성공적으로 한다. - Void") @Test - void registerQueue() throws Exception { + void registerQueue_success() throws Exception { // Given Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); @@ -284,15 +308,15 @@ void registerQueue() throws Exception { .andExpect(status().isOk()); } - @WithMember(nickname = "member-coupon-2") - @DisplayName("POST - 발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") + @WithMember + @DisplayName("POST - 발급이 가능한 쿠폰이 없는 상황에 쿠폰 발급 요청을 한다. - BadRequestException") @Test - void registerQueue_BadRequestException() throws Exception { + void registerQueue_Zero_StartAt_BadRequestException() throws Exception { // Given Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 2, 1, 1, 1)); + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -307,12 +331,35 @@ void registerQueue_BadRequestException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } + @WithMember + @DisplayName("POST - 발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") + @Test + void registerQueue_Not_StartAt_BadRequestException() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon(); + couponRepository.save(couponFixture); + + given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 2, 1, 1, 1)); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", "not start couponName")) + .andDo(print()) + .andDo(document("coupons", + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint()), + ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + .andExpect(status().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + } + @WithMember @DisplayName("POST - 존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") @Test void registerQueue_NotFoundException() throws Exception { // Given - Coupon coupon = CouponFixture.coupon("Not found coupon name", 2, 1); + Coupon coupon = CouponFixture.coupon("Not found couponName", 2, 1); given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 2, 1, 1, 1)); @@ -324,8 +371,8 @@ void registerQueue_NotFoundException() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) - .andExpect(status().isNotFound()) + .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } } diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 9d02a077..647eecaa 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -1,14 +1,17 @@ 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 org.springframework.data.redis.core.DefaultTypedTuple; +import org.springframework.data.redis.core.ZSetOperations; 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.CouponStatusRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; @@ -77,13 +80,6 @@ public static Coupon discount10000Coupon() { .build(); } - public static CouponWallet couponWallet(Long memberId, Coupon coupon) { - return CouponWallet.builder() - .memberId(memberId) - .coupon(coupon) - .build(); - } - public static CreateCouponRequest createCouponRequest() { return CreateCouponRequest.builder() .name("couponName") @@ -131,4 +127,20 @@ public static Stream provideCoupons() { )) ); } + + public static Stream provideTypedTuples() { + Set> tuples = new HashSet<>(); + tuples.add(new DefaultTypedTuple<>(1L, 1.0)); + tuples.add(new DefaultTypedTuple<>(2L, 2.0)); + tuples.add(new DefaultTypedTuple<>(3L, 3.0)); + tuples.add(new DefaultTypedTuple<>(4L, 4.0)); + tuples.add(new DefaultTypedTuple<>(5L, 5.0)); + tuples.add(new DefaultTypedTuple<>(6L, 6.0)); + tuples.add(new DefaultTypedTuple<>(7L, 7.0)); + tuples.add(new DefaultTypedTuple<>(8L, 8.0)); + tuples.add(new DefaultTypedTuple<>(9L, 9.0)); + tuples.add(new DefaultTypedTuple<>(10L, 10.0)); + + return Stream.of(Arguments.of(tuples)); + } } From 5e6f7d27187d004638e2892f9049cb7ca960c591 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Sun, 26 Nov 2023 13:42:26 +0900 Subject: [PATCH 083/185] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0,=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=A0=91?= =?UTF-8?q?=EA=B7=BC=20=EC=A0=9C=EC=96=B4=EC=9E=90,=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EB=AA=85,=20=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#148)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style : Schedule 어노테이션 위치 변경 * refactor: 쿠폰 발행 기간 하루로 통일 및 쿠폰 정보 오픈 날짜 추가 * feat: 쿠폰 발행 가능 날짜 중복 체크 기능 추가 * refactor: Builder 삭제 * test: 쿠폰 관련 테스트 수정 * feat: 쿠폰 발행 관련 레포지토리 기능 구현 및 테스트 * test: 쿠폰 발행 관련 문자열 레디스 기능 구현 및 테스트 * feat: 쿠폰 발행 관련 ZSET 레디스 기능 구현 및 테스트 * test: 쿠폰 발행 컨트롤러 기능 테스트 * test: RestDoc 업데이트 * test: Github Actions 시, Redis ZSET 명령어 못찾는 테스트 Disable * refactor: 알림 및 쿠폰 테스트 코드 메서드명 변경 및 알림 콕 알림 키 변경 * refactor: LocalDate 코드 리뷰 반영 --- .../coupon/CouponManageService.java | 4 +- .../api/application/coupon/CouponService.java | 4 +- .../notification/NotificationService.java | 19 ++---- .../repository/CouponManageRepository.java | 12 ++-- .../repository/NotificationRepository.java | 23 ++++--- .../api/infrastructure/fcm/FcmRepository.java | 12 ++-- ...ository.java => ValueRedisRepository.java} | 2 +- src/main/resources/static/docs/coupon.html | 54 +++++++++------- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 30 ++++----- .../coupon/CouponManageServiceTest.java | 15 ++--- .../application/coupon/CouponServiceTest.java | 14 ++-- .../notification/NotificationServiceTest.java | 41 ++++++------ .../moabam/api/domain/coupon/CouponTest.java | 8 +-- .../api/domain/coupon/CouponTypeTest.java | 6 +- .../api/domain/coupon/CouponWalletTest.java | 4 +- .../CouponManageRepositoryTest.java | 16 ++--- .../CouponSearchRepositoryTest.java | 8 +-- .../CouponWalletSearchRepositoryTest.java | 4 +- .../NotificationRepositoryTest.java | 64 ++++++++++++++----- .../dto/coupon/CreateCouponRequestTest.java | 2 +- .../infrastructure/fcm/FcmRepositoryTest.java | 40 +++++++----- .../infrastructure/fcm/FcmServiceTest.java | 14 ++-- ...est.java => ValueRedisRepositoryTest.java} | 30 ++++----- .../redis/ZSetRedisRepositoryTest.java | 14 ++-- .../presentation/CouponControllerTest.java | 12 ++-- .../NotificationControllerTest.java | 38 ++++++----- 27 files changed, 269 insertions(+), 223 deletions(-) rename src/main/java/com/moabam/api/infrastructure/redis/{StringRedisRepository.java => ValueRedisRepository.java} (95%) rename src/test/java/com/moabam/api/infrastructure/redis/{StringRedisRepositoryTest.java => ValueRedisRepositoryTest.java} (64%) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index 3fd13b1b..f4d1ef92 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -35,7 +35,7 @@ public class CouponManageService { @Scheduled(fixedDelay = 1000) public void issue() { - LocalDate now = LocalDate.from(clockHolder.times()); + LocalDate now = clockHolder.date(); Optional isCoupon = couponRepository.findByStartAt(now); if (!canIssue(isCoupon)) { @@ -69,7 +69,7 @@ public void deleteCouponManage(String couponName) { } private void validateRegister(String couponName) { - LocalDate now = LocalDate.from(clockHolder.times()); + LocalDate now = clockHolder.date(); Optional coupon = couponRepository.findByStartAt(now); if (coupon.isEmpty() || !coupon.get().getName().equals(couponName)) { diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index b8310e7d..8659bebf 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -69,7 +69,7 @@ public Coupon getByWalletIdAndMemberId(Long couponWalletId, Long memberId) { } public List getAllByStatus(CouponStatusRequest request) { - LocalDate now = LocalDate.from(clockHolder.times()); + LocalDate now = clockHolder.date(); List coupons = couponSearchRepository.findAllByStatus(now, request); return coupons.stream() @@ -78,7 +78,7 @@ public List getAllByStatus(CouponStatusRequest request) { } private void validatePeriod(LocalDate startAt, LocalDate openAt) { - LocalDate now = LocalDate.from(clockHolder.times()); + LocalDate now = clockHolder.date(); if (!now.isBefore(startAt)) { throw new BadRequestException(ErrorMessage.INVALID_COUPON_START_AT_PERIOD); diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 11f408e4..a0857dd3 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -30,7 +30,6 @@ public class NotificationService { private static final String KNOCK_BODY = "%s님이 콕 찔렀습니다."; private static final String CERTIFY_TIME_BODY = "%s방 인증 시간입니다."; - private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; private final FcmService fcmService; private final RoomService roomService; @@ -41,13 +40,11 @@ public class NotificationService { @Transactional public void sendKnock(AuthMember member, Long targetId, Long roomId) { roomService.validateRoomById(roomId); - - String knockKey = generateKnockKey(member.id(), targetId, roomId); - validateConflictKnock(knockKey); + validateConflictKnock(member.id(), targetId, roomId); String fcmToken = fcmService.findTokenByMemberId(targetId); fcmService.sendAsync(fcmToken, String.format(KNOCK_BODY, member.nickname())); - notificationRepository.saveKnock(knockKey); + notificationRepository.saveKnock(member.id(), targetId, roomId); } @Scheduled(cron = "0 50 * * * *") @@ -68,8 +65,8 @@ public List getMyKnockStatusInRoom(Long memberId, Long roomId, List !participant.getMemberId().equals(memberId)) .toList(); - Predicate knockPredicate = targetId - -> notificationRepository.existsKnockByKey(generateKnockKey(memberId, targetId, roomId)); + Predicate knockPredicate = targetId -> + notificationRepository.existsKnockByKey(memberId, targetId, roomId); Map> knockStatus = filteredParticipants.stream() .map(Participant::getMemberId) @@ -78,13 +75,9 @@ public List getMyKnockStatusInRoom(Long memberId, Long roomId, List popMinQueue(String couponName, long count) { } public void deleteQueue(String couponName) { - stringRedisRepository.delete(requireNonNull(couponName)); + valueRedisRepository.delete(requireNonNull(couponName)); } public int increaseIssuedStock(String couponName) { String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - return stringRedisRepository + return valueRedisRepository .increment(requireNonNull(stockKey)) .intValue(); } public int getIssuedStock(String couponName) { String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - String stockValue = stringRedisRepository.get(requireNonNull(stockKey)); + String stockValue = valueRedisRepository.get(requireNonNull(stockKey)); if (stockValue == null) { return 0; @@ -58,6 +58,6 @@ public int getIssuedStock(String couponName) { public void deleteIssuedStock(String couponName) { String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - stringRedisRepository.delete(requireNonNull(stockKey)); + valueRedisRepository.delete(requireNonNull(stockKey)); } } 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 index aaffa123..30cdd5d3 100644 --- a/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository; -import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; import lombok.RequiredArgsConstructor; @@ -15,19 +15,22 @@ @RequiredArgsConstructor public class NotificationRepository { + private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; private static final long EXPIRE_KNOCK = 12; - private final StringRedisRepository stringRedisRepository; + private final ValueRedisRepository valueRedisRepository; - public void saveKnock(String key) { - stringRedisRepository.save( - requireNonNull(key), - BLANK, - Duration.ofHours(EXPIRE_KNOCK) - ); + public void saveKnock(Long memberId, Long targetId, Long roomId) { + String knockKey = + String.format(KNOCK_KEY, requireNonNull(roomId), requireNonNull(memberId), requireNonNull(targetId)); + + valueRedisRepository.save(knockKey, BLANK, Duration.ofHours(EXPIRE_KNOCK)); } - public boolean existsKnockByKey(String key) { - return stringRedisRepository.hasKey(requireNonNull(key)); + public boolean existsKnockByKey(Long memberId, Long targetId, Long roomId) { + String knockKey = + String.format(KNOCK_KEY, requireNonNull(roomId), requireNonNull(memberId), requireNonNull(targetId)); + + return valueRedisRepository.hasKey(requireNonNull(knockKey)); } } diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java index eaa5c55b..39b97666 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Repository; -import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; import lombok.RequiredArgsConstructor; @@ -16,10 +16,10 @@ public class FcmRepository { private static final long EXPIRE_FCM_TOKEN = 60; - private final StringRedisRepository stringRedisRepository; + private final ValueRedisRepository valueRedisRepository; public void saveToken(Long memberId, String fcmToken) { - stringRedisRepository.save( + valueRedisRepository.save( String.valueOf(requireNonNull(memberId)), requireNonNull(fcmToken), Duration.ofDays(EXPIRE_FCM_TOKEN) @@ -27,14 +27,14 @@ public void saveToken(Long memberId, String fcmToken) { } public void deleteTokenByMemberId(Long memberId) { - stringRedisRepository.delete(String.valueOf(requireNonNull(memberId))); + valueRedisRepository.delete(String.valueOf(requireNonNull(memberId))); } public String findTokenByMemberId(Long memberId) { - return stringRedisRepository.get(String.valueOf(requireNonNull(memberId))); + return valueRedisRepository.get(String.valueOf(requireNonNull(memberId))); } public boolean existsTokenByMemberId(Long memberId) { - return stringRedisRepository.hasKey(String.valueOf(requireNonNull(memberId))); + return valueRedisRepository.hasKey(String.valueOf(requireNonNull(memberId))); } } diff --git a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java similarity index 95% rename from src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java rename to src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java index c5d4d9df..9f456e9a 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/StringRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java @@ -9,7 +9,7 @@ @Repository @RequiredArgsConstructor -public class StringRedisRepository { +public class ValueRedisRepository { private final RedisTemplate redisTemplate; diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 5678c54e..1b26ba45 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 183
+Content-Length: 175
 Host: localhost:8080
 
 {
@@ -479,9 +479,11 @@ 

응답

HTTP/1.1 201 Created
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin: +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

@@ -496,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/1 HTTP/1.1
+
DELETE /admins/coupons/27 HTTP/1.1
 Host: localhost:8080
@@ -504,9 +506,11 @@

응답

HTTP/1.1 200 OK
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin: +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

@@ -522,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/26 HTTP/1.1
+
GET /coupons/16 HTTP/1.1
 Host: localhost:8080
@@ -530,11 +534,13 @@

응답

HTTP/1.1 200 OK
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin:
+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: 215
+Content-Length: 205
 
 {
   "id" : 16,
@@ -565,7 +571,7 @@ 

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 44
+Content-Length: 41
 Host: localhost:8080
 
 {
@@ -578,11 +584,13 @@ 

응답

HTTP/1.1 200 OK
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin:
+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: 216
+Content-Length: 206
 
 [ {
   "id" : 17,
@@ -623,9 +631,11 @@ 

응답

HTTP/1.1 400 Bad Request
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin:
+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: 64
 
@@ -660,7 +670,7 @@ 

쿠폰 사용 (진행 중)

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 9996cde1..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 3340a231..d56f528e 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -468,16 +468,12 @@

요청

응답

-
HTTP/1.1 404 Not Found
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
-Content-Type: application/json
-Content-Length: 64
-
-{
-  "message" : "해당 유저는 접속 중이 아닙니다."
-}
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin:
+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
@@ -494,18 +490,20 @@

요청

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

응답

HTTP/1.1 200 OK
-Vary: Origin
-Vary: Access-Control-Request-Method
-Vary: Access-Control-Request-Headers
+Access-Control-Allow-Origin: +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
@@ -515,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index 214ca0ab..e347aba6 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -5,7 +5,6 @@ import static org.mockito.BDDMockito.*; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.HashSet; import java.util.Optional; import java.util.Set; @@ -56,7 +55,7 @@ void issue_all_success() { Coupon coupon = CouponFixture.coupon(1000, 100); Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); @@ -73,7 +72,7 @@ void issue_all_success() { @Test void issue_notStartAt() { // Given - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); // When @@ -92,7 +91,7 @@ void issue_stockEnd() { // Given Coupon coupon = CouponFixture.coupon(1000, 100); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(coupon.getStock()); @@ -112,7 +111,7 @@ void issue_queue_stockENd() { Coupon coupon = CouponFixture.coupon(1000, 100); Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); @@ -133,7 +132,7 @@ void register_success() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); // When @@ -150,7 +149,7 @@ void register_StartAt_BadRequestException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); // When & Then @@ -167,7 +166,7 @@ void register_Name_BadRequestException() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); // When & Then diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index d929f46b..075323af 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -4,7 +4,6 @@ import static org.mockito.BDDMockito.*; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -63,7 +62,7 @@ void create_success() { CreateCouponRequest request = CouponFixture.createCouponRequest(); given(couponRepository.existsByName(any(String.class))).willReturn(false); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When couponService.create(admin, request); @@ -95,7 +94,7 @@ void create_Type_NotFoundException() { CreateCouponRequest request = CouponFixture.createCouponRequest("UNKNOWN", 2, 1); given(couponRepository.existsByName(any(String.class))).willReturn(false); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) @@ -144,7 +143,7 @@ void create_StartAt_BadRequestException() { AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); - given(clockHolder.times()).willReturn(LocalDateTime.of(2025, 1, 1, 1, 1)); + 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); @@ -165,7 +164,7 @@ void create_OpenAt_BadRequestException() { given(couponRepository.existsByName(any(String.class))).willReturn(false); given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(false); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then assertThatThrownBy(() -> couponService.create(admin, request)) @@ -180,6 +179,7 @@ void delete_success() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(10, 100); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); // When @@ -209,6 +209,7 @@ void delete_Admin_NotFoundException() { void delete_NotFoundException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); // When & Then @@ -222,6 +223,7 @@ void delete_NotFoundException() { void getById_success() { // Given Coupon coupon = CouponFixture.coupon(10, 100); + given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); // When @@ -251,7 +253,7 @@ void getAllByStatus_success(List coupons) { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); - given(clockHolder.times()).willReturn(LocalDateTime.now()); + given(clockHolder.date()).willReturn(LocalDate.now()); given(couponSearchRepository.findAllByStatus(any(LocalDate.class), any(CouponStatusRequest.class))) .willReturn(coupons); diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 60e7e65f..a85c0fc9 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -19,7 +19,6 @@ 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.FcmRepository; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; @@ -34,43 +33,41 @@ class NotificationServiceTest { @InjectMocks - private NotificationService notificationService; + NotificationService notificationService; @Mock - private RoomService roomService; + RoomService roomService; @Mock - private FcmRepository fcmRepository; + FcmService fcmService; @Mock - private FcmService fcmService; + NotificationRepository notificationRepository; @Mock - private NotificationRepository notificationRepository; + ParticipantSearchRepository participantSearchRepository; @Mock - private ParticipantSearchRepository participantSearchRepository; - - @Mock - private ClockHolder clockHolder; + ClockHolder clockHolder; @WithMember - @DisplayName("성공적으로 상대에게 콕 알림을 보낸다. - Void") + @DisplayName("상대에게 콕 알림을 성공적으로 보낸다. - Void") @Test - void sendKnock() { + void sendKnock_success() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); given(fcmService.findTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); // When notificationService.sendKnock(member, 2L, 1L); // Then verify(fcmService).sendAsync(any(String.class), any(String.class)); - verify(notificationRepository).saveKnock(any(String.class)); + verify(notificationRepository).saveKnock(any(Long.class), any(Long.class), any(Long.class)); } @WithMember @@ -79,6 +76,7 @@ void sendKnock() { void sendKnock_Room_NotFoundException() { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); + willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); // When & Then @@ -94,7 +92,8 @@ void sendKnock_FcmToken_NotFoundException() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); given(fcmService.findTokenByMemberId(any(Long.class))) .willThrow(new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); @@ -112,7 +111,8 @@ void sendKnock_ConflictException() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(true); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(true); // When & Then assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) @@ -123,7 +123,7 @@ void sendKnock_ConflictException() { @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낸다. - Void") @MethodSource("com.moabam.support.fixture.ParticipantFixture#provideParticipants") @ParameterizedTest - void sendCertificationTime(List participants) { + void sendCertificationTime_success(List participants) { // Given given(participantSearchRepository.findAllByRoomCertifyTime(any(Integer.class))).willReturn(participants); given(fcmService.findTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); @@ -161,7 +161,8 @@ void getMyKnockStatusInRoom_knocked(List participants) { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(true); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(true); // When List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); @@ -178,8 +179,8 @@ void getMyKnockStatusInRoom_notKnocked(List participants) { // Given AuthMember member = AuthorizationThreadLocal.getAuthMember(); - // given - given(notificationRepository.existsKnockByKey(any(String.class))).willReturn(false); + given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) + .willReturn(false); // When List actual = notificationService.getMyKnockStatusInRoom(member.id(), 1L, participants); diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java index 2ab0e438..e1e146e3 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -13,9 +13,9 @@ class CouponTest { - @DisplayName("쿠폰이 정상적으로 생성된다. - Coupon") + @DisplayName("쿠폰이 성공적으로 생성된다. - Coupon") @Test - void coupon() { + void coupon_success() { // Given LocalDate startAt = LocalDate.of(2023, 2, 1); LocalDate openAt = LocalDate.of(2023, 1, 1); @@ -44,7 +44,7 @@ void coupon() { @DisplayName("쿠폰 보너스 포인트가 1보다 작다. - BadRequestException") @Test - void coupon_validatePoint_Point_BadRequestException() { + void validatePoint_BadRequestException() { // When& Then assertThatThrownBy(() -> CouponFixture.coupon(0, 1)) .isInstanceOf(BadRequestException.class) @@ -53,7 +53,7 @@ void coupon_validatePoint_Point_BadRequestException() { @DisplayName("쿠폰 재고가 1보다 작다. - BadRequestException") @Test - void coupon_validatePoint_Stock_BadRequestException() { + void validateStock_BadRequestException() { // When& Then assertThatThrownBy(() -> CouponFixture.coupon(1, 0)) .isInstanceOf(BadRequestException.class) diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java index 406bc061..960df900 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java @@ -10,9 +10,9 @@ class CouponTypeTest { - @DisplayName("존재하는 쿠폰을 가져온다. - CouponType") + @DisplayName("존재하는 쿠폰을 성공적으로 가져온다. - CouponType") @Test - void couponType_from() { + void from_success() { // When CouponType actual = CouponType.from(CouponType.GOLDEN_COUPON.getName()); @@ -22,7 +22,7 @@ void couponType_from() { @DisplayName("존재하지 않는 쿠폰을 가져온다. - NotFoundException") @Test - void couponType_from_NotFoundException() { + void from_NotFoundException() { // When & Then assertThatThrownBy(() -> CouponType.from("Not-Coupon")) .isInstanceOf(NotFoundException.class) diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java index 059a9838..13bcd9e5 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponWalletTest.java @@ -9,9 +9,9 @@ class CouponWalletTest { - @DisplayName("쿠폰 지갑 엔티티를 생성한다. - Void") + @DisplayName("쿠폰 지갑 엔티티를 성공적으로 생성한다. - Void") @Test - void couponWallet() { + void couponWallet_success() { // Given Coupon coupon = CouponFixture.coupon("CouponName", 1, 2); 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 index 03c7cf49..2dd66862 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java @@ -16,7 +16,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.redis.core.ZSetOperations.TypedTuple; -import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; import com.moabam.api.infrastructure.redis.ZSetRedisRepository; @ExtendWith(MockitoExtension.class) @@ -29,7 +29,7 @@ class CouponManageRepositoryTest { ZSetRedisRepository zSetRedisRepository; @Mock - StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; @DisplayName("쿠폰 대기열에 사용자가 성공적으로 등록된다. - Void") @Test @@ -57,7 +57,7 @@ void addIfAbsentQueue_memberId_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("쿠폰 대기열에서 10명을 꺼내고 삭제한다.") + @DisplayName("쿠폰 대기열에서 성공적으로 10명을 꺼내고 삭제한다.") @MethodSource("com.moabam.support.fixture.CouponFixture#provideTypedTuples") @ParameterizedTest void popMinQueue_success(Set> tuples) { @@ -86,7 +86,7 @@ void deleteQueue_success() { couponManageRepository.deleteQueue("couponName"); // Then - verify(stringRedisRepository).delete(any(String.class)); + verify(valueRedisRepository).delete(any(String.class)); } @DisplayName("쿠폰명이 Null인 대기열을 삭제한다. - NullPointerException") @@ -101,7 +101,7 @@ void deleteQueue_NullPointerException() { @Test void increaseIssuedStock_success() { // Given - given(stringRedisRepository.increment(any(String.class))).willReturn(77L); + given(valueRedisRepository.increment(any(String.class))).willReturn(77L); // When int actual = couponManageRepository.increaseIssuedStock("couponName"); @@ -122,7 +122,7 @@ void increaseIssuedStock_NullPointerException() { @Test void getIssuedStock_success() { // Given - given(stringRedisRepository.get(any(String.class))).willReturn("1"); + given(valueRedisRepository.get(any(String.class))).willReturn("1"); // When int actual = couponManageRepository.getIssuedStock("couponName"); @@ -135,7 +135,7 @@ void getIssuedStock_success() { @Test void getIssuedStock_zero() { // Given - given(stringRedisRepository.get(any(String.class))).willReturn(null); + given(valueRedisRepository.get(any(String.class))).willReturn(null); // When int actual = couponManageRepository.getIssuedStock("couponName"); @@ -159,7 +159,7 @@ void deleteIssuedStock_success() { couponManageRepository.deleteIssuedStock("couponName"); // Then - verify(stringRedisRepository).delete(any(String.class)); + verify(valueRedisRepository).delete(any(String.class)); } @DisplayName("쿠폰명이 Null인 할당된 쿠폰 재고를 삭제한다. - NullPointerException") 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 index 22deb4da..67fdf033 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -22,15 +22,15 @@ class CouponSearchRepositoryTest { @Autowired - private CouponRepository couponRepository; + CouponRepository couponRepository; @Autowired - private CouponSearchRepository couponSearchRepository; + CouponSearchRepository couponSearchRepository; - @DisplayName("발급 가능한 쿠폰을 조회한다. - List") + @DisplayName("발급 가능한 쿠폰을 성공적으로 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponFixture#provideCoupons") @ParameterizedTest - void findAllByStatus(List coupons) { + void findAllByStatus_success(List coupons) { // Given CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); LocalDate now = LocalDate.of(2023, 7, 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 index db5176c7..b88d5897 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -23,9 +23,9 @@ class CouponWalletSearchRepositoryTest { @Autowired private CouponWalletSearchRepository couponWalletSearchRepository; - @DisplayName("회원의 특정 쿠폰 지갑을 조회한다.") + @DisplayName("회원의 특정 쿠폰 지갑을 성공적으로 조회한다.") @Test - void find_by_id_and_member_id() { + void findByIdAndMemberId_success() { // given Long id = 1L; Long memberId = 1L; 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 index 6a9a376d..681a9aa5 100644 --- a/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/notification/repository/NotificationRepositoryTest.java @@ -12,50 +12,82 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; @ExtendWith(MockitoExtension.class) class NotificationRepositoryTest { @InjectMocks - private NotificationRepository notificationRepository; + NotificationRepository notificationRepository; @Mock - private StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; @DisplayName("콕 알림이 성공적으로 저장된다. - Void") @Test - void notificationRepository_saveKnockNotification() { + void saveKnock_success() { // When - notificationRepository.saveKnock("knockKey"); + notificationRepository.saveKnock(1L, 1L, 1L); // Then - verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + verify(valueRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); } - @DisplayName("콕 알림 저장 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("콕 찌르는 사용자의 ID가 Null인 콕 알림을 저장한다. - NullPointerException") @Test - void notificationRepository_saveKnockNotification_NullPointerException() { + void saveKnock_MemberId_NullPointerException() { // When & Then - assertThatThrownBy(() -> notificationRepository.saveKnock(null)) + assertThatThrownBy(() -> notificationRepository.saveKnock(null, 1L, 1L)) .isInstanceOf(NullPointerException.class); } - @DisplayName("콕 알림 여부 체크를 정상적으로 확인한다. - Boolean") + @DisplayName("콕 찌를 대상의 ID가 Null인 콕 알림을 저장한다. - NullPointerException") @Test - void notificationRepository_existsKnockByMemberId() { + 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("knock key"); + notificationRepository.existsKnockByKey(1L, 1L, 1L); // Then - verify(stringRedisRepository).hasKey(any(String.class)); + 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("콕 알림 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("방 ID가 Null인 콕 알림 여부를 체크한다. - NullPointerException") @Test - void notificationRepository_existsKnockByMemberId_NullPointerException() { + void existsKnockByKey_RoomId_NullPointerException() { // When & Then - assertThatThrownBy(() -> notificationRepository.existsKnockByKey(null)) + assertThatThrownBy(() -> notificationRepository.existsKnockByKey(1L, 2L, null)) .isInstanceOf(NullPointerException.class); } } diff --git a/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java index 5fcf7561..3454611b 100644 --- a/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java +++ b/src/test/java/com/moabam/api/dto/coupon/CreateCouponRequestTest.java @@ -15,7 +15,7 @@ class CreateCouponRequestTest { @DisplayName("쿠폰 발급 가능 시작 날짜가 올바른 형식으로 입력된다. - yyyy-MM-dd") @Test - void createCouponRequest_StartAt() throws JsonProcessingException { + void startAt_success() throws JsonProcessingException { // Given ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java index 4c6f4367..83f2e887 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java @@ -13,46 +13,54 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.infrastructure.redis.StringRedisRepository; +import com.moabam.api.infrastructure.redis.ValueRedisRepository; @ExtendWith(MockitoExtension.class) class FcmRepositoryTest { @InjectMocks - private FcmRepository fcmRepository; + FcmRepository fcmRepository; @Mock - private StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") @Test - void saveToken() { + void saveToken_success() { // When fcmRepository.saveToken(1L, "value1"); // Then - verify(stringRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); + verify(valueRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); } - @DisplayName("FCM 토큰 저장 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 저장한다. - NullPointerException") @Test - void saveToken_NullPointerException() { + void saveToken_MemberId_NullPointerException() { // When & Then assertThatThrownBy(() -> fcmRepository.saveToken(null, "value")) .isInstanceOf(NullPointerException.class); } + @DisplayName("토큰이 Null인 FCM 토큰을 저장한다. - NullPointerException") + @Test + void saveToken_FcmToken_NullPointerException() { + // When & Then + assertThatThrownBy(() -> fcmRepository.saveToken(1L, null)) + .isInstanceOf(NullPointerException.class); + } + @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") @Test - void deleteTokenByMemberId() { + void deleteTokenByMemberId_success() { // When fcmRepository.deleteTokenByMemberId(1L); // Then - verify(stringRedisRepository).delete(any(String.class)); + verify(valueRedisRepository).delete(any(String.class)); } - @DisplayName("FCM 토큰 삭제 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 삭제한다.. - NullPointerException") @Test void deleteTokenByMemberId_NullPointerException() { // When & Then @@ -62,15 +70,15 @@ void deleteTokenByMemberId_NullPointerException() { @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") @Test - void findTokenByMemberId() { + void findTokenByMemberId_success() { // When fcmRepository.findTokenByMemberId(1L); // Then - verify(stringRedisRepository).get(any(String.class)); + verify(valueRedisRepository).get(any(String.class)); } - @DisplayName("FCM 토큰 조회 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 조회한다. - NullPointerException") @Test void findTokenByMemberId_NullPointerException() { // When & Then @@ -80,15 +88,15 @@ void findTokenByMemberId_NullPointerException() { @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인한다. - Boolean") @Test - void existsTokenByMemberId() { + void existsTokenByMemberId_success() { // When fcmRepository.existsTokenByMemberId(1L); // Then - verify(stringRedisRepository).hasKey(any(String.class)); + verify(valueRedisRepository).hasKey(any(String.class)); } - @DisplayName("FCM 토큰 존재 여부 체크 시, 필요한 값이 NULL 이다. - NullPointerException") + @DisplayName("ID가 Null인 사용자가 FCM 토큰 존재 여부를 확인한다. - NullPointerException") @Test void existsTokenByMemberId_NullPointerException() { // When & Then diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java index 218e4ef7..c4b7cc09 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -20,18 +20,18 @@ class FcmServiceTest extends WithoutFilterSupporter { @Autowired - private FcmService fcmService; + FcmService fcmService; @MockBean - private FirebaseMessaging firebaseMessaging; + FirebaseMessaging firebaseMessaging; @MockBean - private FcmRepository fcmRepository; + FcmRepository fcmRepository; @WithMember @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") @Test - void saveToken() { + void saveToken_success() { // Given AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); @@ -72,7 +72,7 @@ void saveToken_Null() { @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") @Test - void deleteTokenByMemberId() { + void deleteTokenByMemberId_success() { // When fcmRepository.deleteTokenByMemberId(1L); @@ -82,7 +82,7 @@ void deleteTokenByMemberId() { @DisplayName("FCM 토큰을 성공적으로 조회된다. - (String) FCM TOKEN") @Test - void findTokenByMemberId() { + void findTokenByMemberId_success() { // When fcmRepository.findTokenByMemberId(1L); @@ -92,7 +92,7 @@ void findTokenByMemberId() { @DisplayName("비동기 FCM 알림을 성공적으로 보낸다. - Void") @Test - void sendAsync() { + void sendAsync_success() { // When fcmService.sendAsync("FCM-TOKEN", "알림"); diff --git a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java similarity index 64% rename from src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java rename to src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java index 83b9d44a..c0566697 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/StringRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java @@ -13,11 +13,11 @@ import com.moabam.global.config.EmbeddedRedisConfig; -@SpringBootTest(classes = {EmbeddedRedisConfig.class, StringRedisRepository.class}) -class StringRedisRepositoryTest { +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ValueRedisRepository.class}) +class ValueRedisRepositoryTest { @Autowired - StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; String key = "key"; String value = "value"; @@ -26,17 +26,17 @@ class StringRedisRepositoryTest { @BeforeEach void setUp() { - stringRedisRepository.save(key, value, duration); + valueRedisRepository.save(key, value, duration); } @AfterEach void setDown() { - if (stringRedisRepository.hasKey(key)) { - stringRedisRepository.delete(key); + if (valueRedisRepository.hasKey(key)) { + valueRedisRepository.delete(key); } - if (stringRedisRepository.hasKey(stockKey)) { - stringRedisRepository.delete(stockKey); + if (valueRedisRepository.hasKey(stockKey)) { + valueRedisRepository.delete(stockKey); } } @@ -44,41 +44,41 @@ void setDown() { @Test void save_success() { // Then - assertThat(stringRedisRepository.get(key)).isEqualTo(value); + assertThat(valueRedisRepository.get(key)).isEqualTo(value); } @DisplayName("레디스의 특정 데이터가 성공적으로 조회된다. - String(Value)") @Test void get_success() { // When - String actual = stringRedisRepository.get(key); + String actual = valueRedisRepository.get(key); // Then - assertThat(actual).isEqualTo(stringRedisRepository.get(key)); + assertThat(actual).isEqualTo(valueRedisRepository.get(key)); } @DisplayName("레디스의 특정 데이터 존재 여부를 성공적으로 체크한다. - Boolean") @Test void hasKey_success() { // When & Then - assertThat(stringRedisRepository.hasKey("not found key")).isFalse(); + assertThat(valueRedisRepository.hasKey("not found key")).isFalse(); } @DisplayName("레디스의 특정 데이터가 성공적으로 삭제된다. - Void") @Test void delete_success() { // When - stringRedisRepository.delete(key); + valueRedisRepository.delete(key); // Then - assertThat(stringRedisRepository.hasKey(key)).isFalse(); + assertThat(valueRedisRepository.hasKey(key)).isFalse(); } @DisplayName("레디스의 특정 데이터의 값이 1 증가한다.") @Test void increment_success() { // When - Long actual = stringRedisRepository.increment(stockKey); + Long actual = valueRedisRepository.increment(stockKey); // 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 index 5b94879d..cfc29bb4 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java @@ -15,14 +15,14 @@ import com.moabam.global.config.EmbeddedRedisConfig; -@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class, StringRedisRepository.class}) +@SpringBootTest(classes = {EmbeddedRedisConfig.class, ZSetRedisRepository.class, ValueRedisRepository.class}) class ZSetRedisRepositoryTest { @Autowired ZSetRedisRepository zSetRedisRepository; @Autowired - StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; @Autowired RedisTemplate redisTemplate; @@ -32,8 +32,8 @@ class ZSetRedisRepositoryTest { @AfterEach void afterEach() { - if (stringRedisRepository.hasKey(key)) { - stringRedisRepository.delete(key); + if (valueRedisRepository.hasKey(key)) { + valueRedisRepository.delete(key); } } @@ -44,7 +44,7 @@ void addIfAbsent_success() { zSetRedisRepository.addIfAbsent(key, value, 1); // Then - assertThat(stringRedisRepository.hasKey(key)).isTrue(); + assertThat(valueRedisRepository.hasKey(key)).isTrue(); } @DisplayName("이미 존재하는 값을 한 번 더 저장을 시도한다. - Void") @@ -72,7 +72,7 @@ void popMin_same_success() { // Then assertThat(actual).hasSize(3); - assertThat(stringRedisRepository.hasKey(key)).isFalse(); + assertThat(valueRedisRepository.hasKey(key)).isFalse(); } @Disabled @@ -106,6 +106,6 @@ void popMin_less_success() { // Then assertThat(actual).hasSize(3); - assertThat(stringRedisRepository.hasKey(key)).isTrue(); + assertThat(valueRedisRepository.hasKey(key)).isTrue(); } } diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index d9fa0bfa..4cbf4981 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -64,7 +65,8 @@ class CouponControllerTest extends WithoutFilterSupporter { void create_Coupon_success() throws Exception { // Given CreateCouponRequest request = CouponFixture.createCouponRequest(); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then mockMvc.perform(post("/admins/coupons") @@ -85,7 +87,7 @@ void create_Coupon_StartAt_BadRequestException() throws Exception { // Given CreateCouponRequest request = CouponFixture.createCouponRequest(); - given(clockHolder.times()).willReturn(LocalDateTime.of(2025, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2025, 1, 1)); // When & Then mockMvc.perform(post("/admins/coupons") @@ -110,7 +112,7 @@ void create_Coupon_OpenAt_BadRequestException() throws Exception { String couponType = CouponType.GOLDEN_COUPON.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then mockMvc.perform(post("/admins/coupons") @@ -272,7 +274,7 @@ void getAllByStatus_Coupon_success(List coupons) throws Exception { CouponStatusRequest request = CouponFixture.couponStatusRequest(false, false); couponRepository.saveAll(coupons); - given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 3, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2023, 3, 1)); // When & Then mockMvc.perform(post("/coupons/search") @@ -296,7 +298,7 @@ void registerQueue_success() throws Exception { Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 2, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); // When & Then mockMvc.perform(post("/coupons") diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index a1d00129..048353d8 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -29,7 +29,7 @@ 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.StringRedisRepository; +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; @@ -43,41 +43,39 @@ @AutoConfigureRestDocs class NotificationControllerTest extends WithoutFilterSupporter { - private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; - @Autowired - private MockMvc mockMvc; + MockMvc mockMvc; @Autowired - private MemberRepository memberRepository; + MemberRepository memberRepository; @Autowired - private RoomRepository roomRepository; + RoomRepository roomRepository; @Autowired - private NotificationRepository notificationRepository; + NotificationRepository notificationRepository; @Autowired - private StringRedisRepository stringRedisRepository; + ValueRedisRepository valueRedisRepository; @Autowired - private FcmService fcmService; + FcmService fcmService; @Autowired - private FcmRepository fcmRepository; + FcmRepository fcmRepository; @MockBean - private FirebaseMessaging firebaseMessaging; + FirebaseMessaging firebaseMessaging; - private Member target; - private Room room; - private String knockKey; + Member target; + Room room; + String knockKey; @BeforeEach void setUp() { target = memberRepository.save(MemberFixture.member("123", "targetName")); room = roomRepository.save(RoomFixture.room()); - knockKey = String.format(KNOCK_KEY, room.getId(), 1, target.getId()); + knockKey = String.format("room_%s_member_%s_knocks_%s", room.getId(), 1, target.getId()); willReturn(null) .given(firebaseMessaging) @@ -87,13 +85,13 @@ void setUp() { @AfterEach void setDown() { fcmService.deleteTokenByMemberId(target.getId()); - stringRedisRepository.delete(knockKey); + valueRedisRepository.delete(knockKey); } @WithMember @DisplayName("POST - 성공적으로 FCM Token을 저장한다. - Void") @Test - void createFcmToken() throws Exception { + void createFcmToken_success() throws Exception { // When & Then mockMvc.perform(post("/notifications") .param("fcmToken", "FCM-TOKEN")) @@ -119,9 +117,9 @@ void createFcmToken_blank() throws Exception { } @WithMember - @DisplayName("GET - 성공적으로 상대에게 콕 알림을 보낸다. - Void") + @DisplayName("GET - 상대에게 콕 알림을 성공적으로 보낸다. - Void") @Test - void sendKnock() throws Exception { + void sendKnock_success() throws Exception { // Given fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); @@ -156,7 +154,7 @@ void sendKnock_NotFoundException() throws Exception { void sendKnock_ConflictException() throws Exception { // Given fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); - notificationRepository.saveKnock(knockKey); + notificationRepository.saveKnock(1L, target.getId(), room.getId()); // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) From 43efc00be117d8c33b5f4c500e4ac4ee6ef0aecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Sun, 26 Nov 2023 14:32:00 +0900 Subject: [PATCH 084/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EB=B3=B4?= =?UTF-8?q?=EA=B4=80=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * style : Schedule 어노테이션 위치 변경 * refactor: 쿠폰 발행 기간 하루로 통일 및 쿠폰 정보 오픈 날짜 추가 * feat: 쿠폰 발행 가능 날짜 중복 체크 기능 추가 * refactor: Builder 삭제 * test: 쿠폰 관련 테스트 수정 * feat: 쿠폰 발행 관련 레포지토리 기능 구현 및 테스트 * test: 쿠폰 발행 관련 문자열 레디스 기능 구현 및 테스트 * feat: 쿠폰 발행 관련 ZSET 레디스 기능 구현 및 테스트 * test: 쿠폰 발행 컨트롤러 기능 테스트 * test: RestDoc 업데이트 * test: Github Actions 시, Redis ZSET 명령어 못찾는 테스트 Disable * refactor: 알림 및 쿠폰 테스트 코드 메서드명 변경 및 알림 콕 알림 키 변경 * feat: 쿠폰함 조회 서비스 기능 구현 및 테스트 * feat: 쿠폰 보관함 저장소 조회 기능 구현 및 테스트 * feat: 쿠폰 보관함 조회 기능 구현 및 테스트 * fix: temporal 에러 해결 * refactor: Stream 코드 리뷰 반영 --- src/docs/asciidoc/coupon.adoc | 9 ++ .../api/application/coupon/CouponMapper.java | 27 +++- .../api/application/coupon/CouponService.java | 27 ++-- .../notification/NotificationService.java | 3 +- .../CouponWalletSearchRepository.java | 13 ++ .../api/dto/coupon/MyCouponResponse.java | 16 +++ .../api/presentation/CouponController.java | 7 ++ src/main/resources/static/docs/coupon.html | 64 +++++++++- .../application/coupon/CouponServiceTest.java | 42 +++++++ .../CouponWalletSearchRepositoryTest.java | 81 +++++++++++- .../presentation/CouponControllerTest.java | 115 ++++++++++++++---- .../NotificationControllerTest.java | 6 +- .../moabam/support/fixture/CouponFixture.java | 12 ++ .../support/fixture/CouponWalletFixture.java | 38 ++++++ .../CouponSnippet.java} | 4 +- .../support/snippet/CouponWalletSnippet.java | 18 +++ .../ErrorSnippet.java} | 4 +- 17 files changed, 433 insertions(+), 53 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java create mode 100644 src/test/java/com/moabam/support/fixture/CouponWalletFixture.java rename src/test/java/com/moabam/support/{fixture/CouponSnippetFixture.java => snippet/CouponSnippet.java} (97%) create mode 100644 src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java rename src/test/java/com/moabam/support/{fixture/ErrorSnippetFixture.java => snippet/ErrorSnippet.java} (83%) diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc index 6e98b9e4..4f424964 100644 --- a/src/docs/asciidoc/coupon.adoc +++ b/src/docs/asciidoc/coupon.adoc @@ -85,6 +85,15 @@ include::{snippets}/coupons/http-response.adoc[] 사용자가 자신의 보관함에 있는 쿠폰들을 조회합니다. +==== 요청 + +include::{snippets}/my-coupons/couponId/http-request.adoc[] + +[discrete] +==== 응답 + +include::{snippets}/my-coupons/couponId/http-response.adoc[] + --- === 쿠폰 사용 (진행 중) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index d38dbb77..ef8eb15a 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -1,9 +1,14 @@ 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; @@ -25,7 +30,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { } // TODO : Admin Table 생성 시, 관리자 명 추가할 예정 - public static CouponResponse toDto(Coupon coupon) { + public static CouponResponse toResponse(Coupon coupon) { return CouponResponse.builder() .id(coupon.getId()) .adminName(coupon.getAdminId() + "admin") @@ -38,4 +43,24 @@ public static CouponResponse toDto(Coupon coupon) { .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() + .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 index 8659bebf..43962390 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -7,6 +7,7 @@ import org.springframework.transaction.annotation.Transactional; 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.CouponWalletSearchRepository; @@ -14,6 +15,7 @@ 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.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.BadRequestException; @@ -59,22 +61,27 @@ public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); - return CouponMapper.toDto(coupon); - } - - public Coupon getByWalletIdAndMemberId(Long couponWalletId, Long memberId) { - return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)) - .getCoupon(); + return CouponMapper.toResponse(coupon); } public List getAllByStatus(CouponStatusRequest request) { LocalDate now = clockHolder.date(); List coupons = couponSearchRepository.findAllByStatus(now, request); - return coupons.stream() - .map(CouponMapper::toDto) - .toList(); + return CouponMapper.toResponses(coupons); + } + + public List getWallet(Long couponId, AuthMember authMember) { + List couponWallets = + couponWalletSearchRepository.findAllByCouponIdAndMemberId(couponId, authMember.id()); + + return CouponMapper.toMyResponses(couponWallets); + } + + public Coupon getByWalletIdAndMemberId(Long couponWalletId, Long memberId) { + return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)) + .getCoupon(); } private void validatePeriod(LocalDate startAt, LocalDate openAt) { diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index a0857dd3..bcbec2af 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -31,11 +31,12 @@ public class NotificationService { private static final String KNOCK_BODY = "%s님이 콕 찔렀습니다."; private static final String CERTIFY_TIME_BODY = "%s방 인증 시간입니다."; + private final ClockHolder clockHolder; private final FcmService fcmService; private final RoomService roomService; + private final NotificationRepository notificationRepository; private final ParticipantSearchRepository participantSearchRepository; - private final ClockHolder clockHolder; @Transactional public void sendKnock(AuthMember member, Long targetId, Long roomId) { 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 index abd93f5c..afcdbf16 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepository.java @@ -3,11 +3,13 @@ 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; @@ -18,6 +20,17 @@ public class CouponWalletSearchRepository { private final JPAQueryFactory jpaQueryFactory; + public List findAllByCouponIdAndMemberId(Long couponId, Long memberId) { + return jpaQueryFactory + .selectFrom(couponWallet) + .join(couponWallet.coupon, coupon).fetchJoin() + .where( + DynamicQuery.generateEq(couponId, couponWallet.coupon.id::eq), + DynamicQuery.generateEq(memberId, couponWallet.memberId::eq) + ) + .fetch(); + } + public Optional findByIdAndMemberId(Long id, Long memberId) { return Optional.ofNullable(jpaQueryFactory .selectFrom(couponWallet) 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..a0201ebb --- /dev/null +++ b/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java @@ -0,0 +1,16 @@ +package com.moabam.api.dto.coupon; + +import com.moabam.api.domain.coupon.CouponType; + +import lombok.Builder; + +@Builder +public record MyCouponResponse( + Long id, + String name, + String description, + int point, + CouponType type +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index e5b03746..e61b5c6b 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -17,6 +17,7 @@ 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; @@ -58,4 +59,10 @@ public List getAllByStatus(@Valid @RequestBody CouponStatusReque public void registerQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { couponManageService.register(authMember, couponName); } + + @GetMapping({"/my-coupons", "/my-coupons/{couponId}"}) + public List getWallet(@Auth AuthMember authMember, + @PathVariable(value = "couponId", required = false) Long couponId) { + return couponService.getWallet(couponId, authMember); + } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 1b26ba45..4b9cc040 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/27 HTTP/1.1
+
DELETE /admins/coupons/33 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/16 HTTP/1.1
+
GET /coupons/22 HTTP/1.1
 Host: localhost:8080
@@ -543,7 +543,7 @@

응답

Content-Length: 205 { - "id" : 16, + "id" : 22, "adminName" : "1admin", "name" : "couponName", "description" : "", @@ -593,7 +593,7 @@

응답

Content-Length: 206 [ { - "id" : 17, + "id" : 23, "adminName" : "1admin", "name" : "coupon1", "description" : "", @@ -654,8 +654,62 @@

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

요청

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

응답

+
+
+
HTTP/1.1 200 OK
+Access-Control-Allow-Origin:
+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: 507
+
+[ {
+  "id" : 17,
+  "name" : "c1",
+  "description" : "",
+  "point" : 10,
+  "type" : "MORNING_COUPON"
+}, {
+  "id" : 18,
+  "name" : "c2",
+  "description" : "",
+  "point" : 10,
+  "type" : "MORNING_COUPON"
+}, {
+  "id" : 19,
+  "name" : "c3",
+  "description" : "",
+  "point" : 10,
+  "type" : "MORNING_COUPON"
+}, {
+  "id" : 20,
+  "name" : "c4",
+  "description" : "",
+  "point" : 10,
+  "type" : "MORNING_COUPON"
+}, {
+  "id" : 21,
+  "name" : "c5",
+  "description" : "",
+  "point" : 10,
+  "type" : "MORNING_COUPON"
+} ]
+
+

+

쿠폰 사용 (진행 중)

@@ -670,7 +724,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 075323af..a8e19049 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -1,5 +1,6 @@ package com.moabam.api.application.coupon; +import static com.moabam.support.fixture.CouponFixture.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -18,12 +19,15 @@ 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.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.auth.model.AuthMember; import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.common.util.ClockHolder; @@ -50,6 +54,9 @@ class CouponServiceTest { @Mock CouponSearchRepository couponSearchRepository; + @Mock + CouponWalletSearchRepository couponWalletSearchRepository; + @Mock ClockHolder clockHolder; @@ -263,4 +270,39 @@ void getAllByStatus_success(List coupons) { // Then assertThat(actual).hasSize(coupons.size()); } + + @WithMember + @DisplayName("나의 쿠폰함을 성공적으로 조회한다.") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletByCouponId1_total5") + @ParameterizedTest + void getWallet_success(List couponWallets) { + // Given + AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); + + given(couponWalletSearchRepository.findAllByCouponIdAndMemberId(isNull(), any(Long.class))) + .willReturn(couponWallets); + + // When + List actual = couponService.getWallet(null, authMember); + + // Then + assertThat(actual).hasSize(couponWallets.size()); + } + + @WithMember + @DisplayName("지갑에서 특정 쿠폰을 성공적으로 조회한다.") + @Test + void getByWalletIdAndMemberId_success() { + // Given + CouponWallet couponWallet = CouponWallet.create(1L, coupon()); + + given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(Optional.of(couponWallet)); + + // When + Coupon actual = couponService.getByWalletIdAndMemberId(1L, 1L); + + // Then + assertThat(actual.getName()).isEqualTo(couponWallet.getCoupon().getName()); + } } 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 index b88d5897..1d8d6526 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -3,13 +3,20 @@ import static com.moabam.support.fixture.CouponFixture.*; import static org.assertj.core.api.Assertions.*; +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 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 { @@ -23,17 +30,83 @@ class CouponWalletSearchRepositoryTest { @Autowired private CouponWalletSearchRepository couponWalletSearchRepository; + @DisplayName("나의 쿠폰함의 특정 쿠폰을 조회한다.. - List") + @Test + void findAllByCouponIdAndMemberId_success() { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // When + List actual = couponWalletSearchRepository.findAllByCouponIdAndMemberId(coupon.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 findAllByCouponIdAndMemberId_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.findAllByCouponIdAndMemberId(null, 1L); + + // Then + assertThat(actual).hasSize(1); + } + + @DisplayName("ID가 2인 회원은 쿠폰 ID가 777인 쿠폰을 가지고 있지 않다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") + @ParameterizedTest + void findAllByCouponIdAndMemberId_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.findAllByCouponIdAndMemberId(777L, 2L); + + // Then + assertThat(actual).isEmpty(); + } + + @DisplayName("ID가 3인 회원은 쿠폰 3개를 가지고 있다. - List") + @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") + @ParameterizedTest + void findAllByCouponIdAndMemberId_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.findAllByCouponIdAndMemberId(null, 3L); + + // Then + assertThat(actual).hasSize(3); + } + @DisplayName("회원의 특정 쿠폰 지갑을 성공적으로 조회한다.") @Test void findByIdAndMemberId_success() { // given - Long id = 1L; - Long memberId = 1L; Coupon coupon = couponRepository.save(discount1000Coupon()); - couponWalletRepository.save(CouponWallet.create(memberId, coupon)); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); // when - CouponWallet actual = couponWalletSearchRepository.findByIdAndMemberId(id, memberId).orElseThrow(); + CouponWallet actual = couponWalletSearchRepository.findByIdAndMemberId(couponWallet.getId(), 1L) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); // then assertThat(actual.getCoupon()).isEqualTo(coupon); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 4cbf4981..880962fb 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -9,7 +9,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -29,7 +28,9 @@ 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.dto.coupon.CouponStatusRequest; import com.moabam.api.dto.coupon.CreateCouponRequest; @@ -38,8 +39,9 @@ import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.CouponFixture; -import com.moabam.support.fixture.CouponSnippetFixture; -import com.moabam.support.fixture.ErrorSnippetFixture; +import com.moabam.support.snippet.CouponSnippet; +import com.moabam.support.snippet.CouponWalletSnippet; +import com.moabam.support.snippet.ErrorSnippet; @Transactional @SpringBootTest @@ -56,6 +58,9 @@ class CouponControllerTest extends WithoutFilterSupporter { @Autowired CouponRepository couponRepository; + @Autowired + CouponWalletRepository couponWalletRepository; + @MockBean ClockHolder clockHolder; @@ -76,7 +81,7 @@ void create_Coupon_success() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST)) + CouponSnippet.CREATE_COUPON_REQUEST)) .andExpect(status().isCreated()); } @@ -97,8 +102,8 @@ void create_Coupon_StartAt_BadRequestException() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST, - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + 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())); @@ -122,8 +127,8 @@ void create_Coupon_OpenAt_BadRequestException() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST, - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + 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())); @@ -145,8 +150,8 @@ void create_Coupon_Name_ConflictException() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST, - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + 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())); @@ -169,8 +174,8 @@ void create_Coupon_StartAt_ConflictException() throws Exception { .andDo(document("admins/coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.CREATE_COUPON_REQUEST, - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + 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())); @@ -202,7 +207,7 @@ void delete_Coupon_NotFoundException() throws Exception { .andDo(document("admins/coupons/couponId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); @@ -220,7 +225,7 @@ void getById_Coupon_success() throws Exception { .andDo(document("coupons/couponId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.COUPON_RESPONSE)) + CouponSnippet.COUPON_RESPONSE)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.id").value(coupon.getId())); @@ -235,7 +240,7 @@ void getById_Coupon_NotFoundException() throws Exception { .andDo(document("coupons/couponId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_COUPON.getMessage())); @@ -249,7 +254,7 @@ void getAllByStatus_Coupons_success(List coupons) throws Exception { CouponStatusRequest request = CouponFixture.couponStatusRequest(true, true); List coupon = couponRepository.saveAll(coupons); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then mockMvc.perform(post("/coupons/search") @@ -259,8 +264,8 @@ void getAllByStatus_Coupons_success(List coupons) throws Exception { .andDo(document("coupons/search", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.COUPON_STATUS_REQUEST, - CouponSnippetFixture.COUPON_STATUS_RESPONSE)) + CouponSnippet.COUPON_STATUS_REQUEST, + CouponSnippet.COUPON_STATUS_RESPONSE)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(coupon.size()))); @@ -284,7 +289,7 @@ void getAllByStatus_Coupon_success(List coupons) throws Exception { .andDo(document("coupons/search", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - CouponSnippetFixture.COUPON_STATUS_REQUEST)) + CouponSnippet.COUPON_STATUS_REQUEST)) .andExpect(status().isOk()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$", hasSize(1))); @@ -318,7 +323,7 @@ void registerQueue_Zero_StartAt_BadRequestException() throws Exception { Coupon couponFixture = CouponFixture.coupon(); Coupon coupon = couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 1, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -327,7 +332,7 @@ void registerQueue_Zero_StartAt_BadRequestException() throws Exception { .andDo(document("coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); @@ -341,7 +346,7 @@ void registerQueue_Not_StartAt_BadRequestException() throws Exception { Coupon couponFixture = CouponFixture.coupon(); couponRepository.save(couponFixture); - given(clockHolder.times()).willReturn(LocalDateTime.of(2022, 2, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2022, 2, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -350,7 +355,7 @@ void registerQueue_Not_StartAt_BadRequestException() throws Exception { .andDo(document("coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); @@ -363,7 +368,7 @@ void registerQueue_NotFoundException() throws Exception { // Given Coupon coupon = CouponFixture.coupon("Not found couponName", 2, 1); - given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 2, 1, 1, 1)); + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); // When & Then mockMvc.perform(post("/coupons") @@ -372,9 +377,69 @@ void registerQueue_NotFoundException() throws Exception { .andDo(document("coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } + + @WithMember + @DisplayName("GET - 나의 쿠폰함에서 특정 쿠폰을 조회한다. - List") + @Test + void getWallet_success() throws Exception { + // Given + Coupon coupon = couponRepository.save(CouponFixture.coupon()); + couponWalletRepository.save(CouponWallet.create(1L, coupon)); + + // When & Then + mockMvc.perform(get("/my-coupons/" + coupon.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 getWallet_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 getWallet_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))); + } } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 048353d8..95581ddf 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -33,9 +33,9 @@ import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; -import com.moabam.support.fixture.ErrorSnippetFixture; import com.moabam.support.fixture.MemberFixture; import com.moabam.support.fixture.RoomFixture; +import com.moabam.support.snippet.ErrorSnippet; @Transactional @SpringBootTest @@ -142,7 +142,7 @@ void sendKnock_NotFoundException() throws Exception { .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage())); @@ -162,7 +162,7 @@ void sendKnock_ConflictException() throws Exception { .andDo(document("notifications/rooms/roomId/members/memberId", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), - ErrorSnippetFixture.ERROR_MESSAGE_RESPONSE)) + 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/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 647eecaa..1c67661c 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -32,6 +32,18 @@ public static Coupon coupon() { .build(); } + public static Coupon coupon(String name, int startAt) { + return Coupon.builder() + .name(name) + .point(10) + .type(CouponType.MORNING_COUPON) + .stock(100) + .startAt(LocalDate.of(2023, startAt, 1)) + .openAt(LocalDate.of(2023, 1, 1)) + .adminId(1L) + .build(); + } + public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") 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/CouponSnippetFixture.java b/src/test/java/com/moabam/support/snippet/CouponSnippet.java similarity index 97% rename from src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java rename to src/test/java/com/moabam/support/snippet/CouponSnippet.java index 40c7c63a..e1e4697f 100644 --- a/src/test/java/com/moabam/support/fixture/CouponSnippetFixture.java +++ b/src/test/java/com/moabam/support/snippet/CouponSnippet.java @@ -1,4 +1,4 @@ -package com.moabam.support.fixture; +package com.moabam.support.snippet; import static org.springframework.restdocs.payload.JsonFieldType.*; import static org.springframework.restdocs.payload.PayloadDocumentation.*; @@ -7,7 +7,7 @@ import org.springframework.restdocs.payload.ResponseFieldsSnippet; import org.springframework.restdocs.snippet.Snippet; -public final class CouponSnippetFixture { +public final class CouponSnippet { public static final RequestFieldsSnippet CREATE_COUPON_REQUEST = requestFields( fieldWithPath("name").type(STRING).description("쿠폰명"), 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..f6d260be --- /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("[].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_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)") + ); +} diff --git a/src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java b/src/test/java/com/moabam/support/snippet/ErrorSnippet.java similarity index 83% rename from src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java rename to src/test/java/com/moabam/support/snippet/ErrorSnippet.java index 4c41f198..69e2c5d3 100644 --- a/src/test/java/com/moabam/support/fixture/ErrorSnippetFixture.java +++ b/src/test/java/com/moabam/support/snippet/ErrorSnippet.java @@ -1,11 +1,11 @@ -package com.moabam.support.fixture; +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 ErrorSnippetFixture { +public class ErrorSnippet { public static final Snippet ERROR_MESSAGE_RESPONSE = responseFields( fieldWithPath("message").type(STRING).description("에러 메시지") From 78d4738590b78256cde65b14fa3db1b07ead4f66 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Sun, 26 Nov 2023 17:34:44 +0900 Subject: [PATCH 085/185] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94=EA=B0=80=20(#1?= =?UTF-8?q?51)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: cors api 요청 위치 변경 * feat: 회원 수정 기능 추가 * feat: 회원 정보 수정 API 및 테스트 코드 추가 * feat: 회원 정보 수정 APi 추가 및 테스트 코드 추가 * refactor: 리뷰 코드 반영 - 일시적 사용하지 않는 코드 제거 - 회원 null값에 대한 예외 Objects로 변경 - ErrorMessage 변경 - 테스트 코드 CsvSource null값 적용 * refactor: null체크 메서드 변경 및 에러 메시지 어순 변경 --- .../api/application/member/MemberService.java | 23 ++++++ .../com/moabam/api/domain/member/Member.java | 11 ++- .../member/repository/MemberRepository.java | 2 + .../api/dto/member/ModifyMemberRequest.java | 8 +++ .../api/presentation/MemberController.java | 24 +++++++ .../global/error/model/ErrorMessage.java | 1 + src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 14 ++-- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../application/member/MemberServiceTest.java | 22 ++++++ .../presentation/MemberControllerTest.java | 72 +++++++++++++++++++ .../support/fixture/ModifyImageFixture.java | 28 ++++++++ 13 files changed, 200 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/member/ModifyMemberRequest.java create mode 100644 src/test/java/com/moabam/support/fixture/ModifyImageFixture.java diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index b54bdfc6..ce4ca41a 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -20,12 +20,15 @@ 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.global.auth.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.common.util.GlobalConstant; 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 @@ -82,6 +85,26 @@ public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse, inventories); } + @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)); + + member.changeNickName(modifyMemberRequest.nickname()); + member.changeIntro(modifyMemberRequest.intro()); + member.changeProfileUri(newProfileUri); + + memberRepository.save(member); + } + + private void validateNickname(String nickname) { + if (StringUtils.isEmpty(nickname) && memberRepository.existsByNickname(nickname)) { + throw new ConflictException(NICKNAME_CONFLICT); + } + } + private List getDefaultSkin(Long searchId) { List inventories = inventorySearchRepository.findDefaultSkin(searchId); if (inventories.size() != GlobalConstant.DEFAULT_SKIN_SIZE) { diff --git a/src/main/java/com/moabam/api/domain/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index b013d7fc..33f3aab4 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -84,6 +84,7 @@ private Member(Long id, String socialId, Bug bug) { this.id = id; this.socialId = requireNonNull(socialId); this.nickname = createNickName(); + this.intro = ""; this.profileImage = BaseImageUrl.MEMBER_PROFILE_URL; this.bug = requireNonNull(bug); this.role = Role.USER; @@ -124,7 +125,15 @@ public void delete(LocalDateTime now) { } public void changeNickName(String nickname) { - this.nickname = nickname; + this.nickname = requireNonNullElse(nickname, this.nickname); + } + + public void changeIntro(String intro) { + this.intro = requireNonNullElse(intro, this.intro); + } + + public void changeProfileUri(String newProfileUri) { + this.profileImage = requireNonNullElse(newProfileUri, profileImage); } private String createNickName() { 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 index bb0d5436..f0cb499b 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberRepository.java @@ -9,4 +9,6 @@ public interface MemberRepository extends JpaRepository { Optional findBySocialId(String id); + + boolean existsByNickname(String nickname); } 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/presentation/MemberController.java b/src/main/java/com/moabam/api/presentation/MemberController.java index 5c5da9a0..4891f5a3 100644 --- a/src/main/java/com/moabam/api/presentation/MemberController.java +++ b/src/main/java/com/moabam/api/presentation/MemberController.java @@ -1,5 +1,7 @@ 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; @@ -7,18 +9,24 @@ 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; @@ -31,6 +39,7 @@ public class MemberController { private final AuthorizationService authorizationService; private final MemberService memberService; + private final ImageService imageService; @GetMapping("/login/oauth") public void socialLogin(HttpServletResponse httpServletResponse) { @@ -65,4 +74,19 @@ public void deleteMember(@Auth AuthMember authMember) { 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/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 0fc50530..3cfa68f2 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -35,6 +35,7 @@ public enum ErrorMessage { MEMBER_NOT_FOUND_BY_MANAGER_OR_NULL("방의 매니저거나 회원이 존재하지 않습니다."), MEMBER_ROOM_EXCEED("참여할 수 있는 방의 개수가 모두 찼습니다."), UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS("카카오 연결 요청 실패로 Rollback하였습니다."), + NICKNAME_CONFLICT("이미 존재하는 닉네임입니다."), INVALID_DEFAULT_SKIN_SIZE("기본 스킨은 2개여야 합니다. 관리자에게 문의하세요"), diff --git a/src/main/resources/config b/src/main/resources/config index 2a1a59a1..2e460460 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2a1a59a16d8e868185c125a58aec0682f3c53f0d +Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 4b9cc040..60f27e8c 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 175
+Content-Length: 183
 Host: localhost:8080
 
 {
@@ -540,7 +540,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 205 +Content-Length: 215 { "id" : 22, @@ -571,7 +571,7 @@

요청

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index d56f528e..a06f23b3 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index c1d9025c..a30978cc 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -26,6 +26,7 @@ import com.moabam.api.dto.auth.AuthorizationTokenInfoResponse; 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.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.BadRequestException; @@ -37,6 +38,7 @@ 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 { @@ -224,4 +226,24 @@ void failBy_overSize(@WithMember AuthMember authMember) { .hasMessage(INVALID_DEFAULT_SKIN_SIZE.getMessage()); } } + + @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)); + + // 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") + ); + } + } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 8fc82662..73e43428 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -3,12 +3,14 @@ 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.Optional; @@ -19,15 +21,18 @@ 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.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; @@ -39,7 +44,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.moabam.api.application.auth.OAuth2AuthorizationServerRequestService; +import com.moabam.api.application.image.ImageService; 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; @@ -55,6 +62,7 @@ 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.global.config.OAuthConfig; import com.moabam.global.error.exception.UnauthorizedException; import com.moabam.global.error.handler.RestTemplateResponseHandler; @@ -111,6 +119,9 @@ class MemberControllerTest extends WithoutFilterSupporter { @Autowired OAuthConfig oAuthConfig; + @SpyBean + ImageService imageService; + RestTemplateBuilder restTemplateBuilder; MockRestServiceServer mockRestServiceServer; @@ -417,4 +428,65 @@ void search_member_failBy_default_skin_size() throws Exception { .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()); + + // expected + mockMvc.perform(multipart(HttpMethod.POST, "/members/modify") + .file(modifyMemberRequest) + .contentType("multipart/form-data") + + .characterEncoding("UTF-8")) + .andExpect(status().is2xxSuccessful()) + .andDo(print()); + } } 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"); + } + +} From 99e9afe170960f5a3e028ba7df8477c131b555f4 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 27 Nov 2023 14:00:45 +0900 Subject: [PATCH 086/185] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: order_id 컬럼 인덱스 설정 * chore: webflux 의존성 추가 * feat: 토스 결제 위젯 승인 API 연동 * feat: 결제 승인 API 구현 * feat: 결제 테이블에 couponWalletId 컬럼 추가 * test: 결제 승인 통합 테스트 * feat: 벌레 상품 구매 시 couponWallet 검증 로직 적용 * fix: couponWalletId를 받도록 수정 * test: couponWallet 적용 테스트 * chore: 불필요한 fixture 제거 * feat: 결제 승인 시 쿠폰 차감 및 벌레 충전 로직 추가 * fix: 쿠폰이 적용된 경우 분기 처리 * chore: config 업데이트 * test: 결제 승인 컨트롤러 통합 테스트 * test: 결제 승인 서비스 테스트 * chore: MockWebServer 의존성 추가 * test: 토스 결제 승인 API 테스트 * fix: checkStyle 오류 수정 * chore: config 업데이트 * refactor: 결제 테이블 coupon_id 컬럼을 discount_amount로 변경 * refactor: 공통 메서드 분리 * feat: 벌레 충전 시 벌레 내역 저장 로직 추가 * style: 중복 메서드 제거 --- build.gradle | 4 + .../moabam/api/application/bug/BugMapper.java | 9 ++ .../api/application/bug/BugService.java | 16 +++- .../api/application/coupon/CouponService.java | 9 +- .../application/payment/PaymentMapper.java | 2 +- .../application/payment/PaymentService.java | 37 ++++++++ .../application/product/ProductMapper.java | 2 +- .../room/CertificationService.java | 9 +- .../java/com/moabam/api/domain/bug/Bug.java | 10 +- .../BugHistorySearchRepository.java | 3 +- .../repository/InventorySearchRepository.java | 26 +++--- .../moabam/api/domain/payment/Payment.java | 49 +++++++--- .../repository/PaymentSearchRepository.java | 27 ++++++ .../dto/payment/ConfirmPaymentRequest.java | 15 +++ .../payment/ConfirmTossPaymentRequest.java | 12 +++ .../payment/ConfirmTossPaymentResponse.java | 21 +++++ .../dto/product/PurchaseProductRequest.java | 2 +- .../payment/TossPaymentMapper.java | 18 ++++ .../payment/TossPaymentService.java | 50 ++++++++++ .../api/presentation/PaymentController.java | 10 +- .../global/config/TossPaymentConfig.java | 11 +++ src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 14 +-- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../payment/PaymentServiceTest.java | 82 ++++++++++++++++- .../com/moabam/api/domain/bug/BugTest.java | 6 +- .../api/domain/payment/PaymentTest.java | 8 +- .../payment/TossPaymentServiceTest.java | 92 +++++++++++++++++++ .../presentation/PaymentControllerTest.java | 64 +++++++++++++ .../support/fixture/PaymentFixture.java | 51 +++++++++- src/test/resources/application.yml | 6 ++ 32 files changed, 605 insertions(+), 66 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/payment/repository/PaymentSearchRepository.java create mode 100644 src/main/java/com/moabam/api/dto/payment/ConfirmPaymentRequest.java create mode 100644 src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java create mode 100644 src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java create mode 100644 src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java create mode 100644 src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java create mode 100644 src/main/java/com/moabam/global/config/TossPaymentConfig.java create mode 100644 src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java diff --git a/build.gradle b/build.gradle index 82badb15..9783e62f 100644 --- a/build.gradle +++ b/build.gradle @@ -50,6 +50,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' @@ -92,6 +93,9 @@ dependencies { // 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' } tasks.named('test') { diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index 0d439458..9f217971 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -28,4 +28,13 @@ public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int qua .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(); + } } diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 46908ae4..18d2a7a2 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -13,8 +13,9 @@ 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.repository.BugHistoryRepository; import com.moabam.api.domain.coupon.Coupon; -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; @@ -34,13 +35,14 @@ public class BugService { private final MemberService memberService; private final CouponService couponService; + private final BugHistoryRepository bugHistoryRepository; private final ProductRepository productRepository; private final PaymentRepository paymentRepository; public BugResponse getBug(Long memberId) { - Member member = memberService.getById(memberId); + Bug bug = memberService.getById(memberId).getBug(); - return BugMapper.toBugResponse(member.getBug()); + return BugMapper.toBugResponse(bug); } public ProductsResponse getBugProducts() { @@ -63,6 +65,14 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, return ProductMapper.toPurchaseProductResponse(payment); } + @Transactional + public void charge(Long memberId, Product bugProduct) { + Bug bug = memberService.getById(memberId).getBug(); + + bug.charge(bugProduct.getQuantity()); + bugHistoryRepository.save(BugMapper.toChargeBugHistory(memberId, bugProduct.getQuantity())); + } + private Product getById(Long productId) { return productRepository.findById(productId) .orElseThrow(() -> new NotFoundException(PRODUCT_NOT_FOUND)); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 43962390..ec96458a 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -10,6 +10,7 @@ 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; @@ -32,9 +33,9 @@ public class CouponService { private final ClockHolder clockHolder; private final CouponManageService couponManageService; - private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; + private final CouponWalletRepository couponWalletRepository; private final CouponWalletSearchRepository couponWalletSearchRepository; @Transactional @@ -57,6 +58,12 @@ public void delete(AuthMember admin, Long couponId) { couponManageService.deleteCouponManage(coupon.getName()); } + @Transactional + public void use(Long memberId, Long couponWalletId) { + Coupon coupon = getByWalletIdAndMemberId(couponWalletId, memberId); + couponRepository.delete(coupon); + } + public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java index c5bf6c56..3495adad 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -19,7 +19,7 @@ public static Payment toPayment(Long memberId, Product product) { .memberId(memberId) .product(product) .order(order) - .amount(product.getPrice()) + .totalAmount(product.getPrice()) .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 index 7d4b762f..0055813b 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -5,9 +5,17 @@ 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.infrastructure.payment.TossPaymentMapper; +import com.moabam.api.infrastructure.payment.TossPaymentService; +import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -17,7 +25,11 @@ @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) { @@ -26,8 +38,33 @@ public void request(Long memberId, Long paymentId, PaymentRequest request) { payment.request(request.orderId()); } + @Transactional + public void confirm(Long memberId, ConfirmPaymentRequest request) { + Payment payment = getByOrderId(request.orderId()); + payment.validateInfo(memberId, request.amount()); + + try { + ConfirmTossPaymentResponse response = tossPaymentService.confirm( + TossPaymentMapper.toConfirmRequest(request.paymentKey(), request.orderId(), request.amount()) + ); + payment.confirm(response.paymentKey(), response.approvedAt()); + + if (payment.isCouponApplied()) { + couponService.use(memberId, payment.getCouponWalletId()); + } + bugService.charge(memberId, payment.getProduct()); + } catch (MoabamException exception) { + payment.fail(request.paymentKey()); + } + } + 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 index 32369eb2..b847d9c1 100644 --- a/src/main/java/com/moabam/api/application/product/ProductMapper.java +++ b/src/main/java/com/moabam/api/application/product/ProductMapper.java @@ -35,7 +35,7 @@ public static PurchaseProductResponse toPurchaseProductResponse(Payment payment) return PurchaseProductResponse.builder() .paymentId(payment.getId()) .orderName(payment.getOrder().getName()) - .price(payment.getAmount()) + .price(payment.getTotalAmount()) .build(); } } diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index 60d4b56d..4d320147 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -1,9 +1,6 @@ package com.moabam.api.application.room; -import static com.moabam.global.error.model.ErrorMessage.DUPLICATED_DAILY_MEMBER_CERTIFICATION; -import static com.moabam.global.error.model.ErrorMessage.INVALID_CERTIFY_TIME; -import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.ROUTINE_NOT_FOUND; +import static com.moabam.global.error.model.ErrorMessage.*; import java.time.LocalDate; import java.time.LocalDateTime; @@ -81,7 +78,7 @@ public void certifyRoom(Long memberId, Long roomId, List imageUrls) { return; } - member.getBug().increaseBug(bugType, roomLevel); + member.getBug().increase(bugType, roomLevel); } public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { @@ -175,6 +172,6 @@ private void provideBugToCompletedMembers(BugType bugType, List completedMember.getBug().increaseBug(bugType, expAppliedRoomLevel)); + .forEach(completedMember -> completedMember.getBug().increase(bugType, expAppliedRoomLevel)); } } diff --git a/src/main/java/com/moabam/api/domain/bug/Bug.java b/src/main/java/com/moabam/api/domain/bug/Bug.java index 246844cf..ae07f4d6 100644 --- a/src/main/java/com/moabam/api/domain/bug/Bug.java +++ b/src/main/java/com/moabam/api/domain/bug/Bug.java @@ -48,7 +48,7 @@ private int validateBugCount(int bug) { public void use(BugType bugType, int price) { int currentBug = getBug(bugType); validateEnoughBug(currentBug, price); - decreaseBug(bugType, price); + decrease(bugType, price); } private int getBug(BugType bugType) { @@ -65,7 +65,7 @@ private void validateEnoughBug(int currentBug, int price) { } } - private void decreaseBug(BugType bugType, int bug) { + private void decrease(BugType bugType, int bug) { switch (bugType) { case MORNING -> this.morningBug -= bug; case NIGHT -> this.nightBug -= bug; @@ -73,11 +73,15 @@ private void decreaseBug(BugType bugType, int bug) { } } - public void increaseBug(BugType bugType, int bug) { + public void increase(BugType bugType, int bug) { switch (bugType) { case MORNING -> this.morningBug += bug; case NIGHT -> this.nightBug += bug; case GOLDEN -> this.goldenBug += bug; } } + + public void charge(int quantity) { + this.goldenBug += quantity; + } } 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 index 26a8f9ff..82a9ae61 100644 --- a/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java @@ -22,8 +22,7 @@ public class BugHistorySearchRepository { private final JPAQueryFactory jpaQueryFactory; public List find(Long memberId, BugActionType actionType, LocalDateTime dateTime) { - return jpaQueryFactory - .selectFrom(bugHistory) + return jpaQueryFactory.selectFrom(bugHistory) .where( DynamicQuery.generateEq(memberId, bugHistory.memberId::eq), DynamicQuery.generateEq(actionType, bugHistory.actionType::eq), 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 index 431cf898..0141d2e4 100644 --- a/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/item/repository/InventorySearchRepository.java @@ -23,23 +23,23 @@ 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() + 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() + return Optional.ofNullable(jpaQueryFactory + .selectFrom(inventory) + .where( + DynamicQuery.generateEq(memberId, inventory.memberId::eq), + DynamicQuery.generateEq(type, inventory.item.type::eq), + inventory.isDefault.isTrue()) + .fetchOne() ); } diff --git a/src/main/java/com/moabam/api/domain/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java index cf55b607..9c1f4dea 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -1,7 +1,6 @@ package com.moabam.api.domain.payment; import static com.moabam.global.error.model.ErrorMessage.*; -import static java.lang.Math.*; import static java.util.Objects.*; import java.time.LocalDateTime; @@ -53,18 +52,17 @@ public class Payment { @JoinColumn(name = "product_id", updatable = false, nullable = false) private Product product; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "coupon_id") - private Coupon coupon; - @Column(name = "coupon_wallet_id") private Long couponWalletId; @Embedded private Order order; - @Column(name = "amount", nullable = false) - private int amount; + @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; @@ -84,11 +82,14 @@ public class Payment { private LocalDateTime approvedAt; @Builder - public Payment(Long memberId, Product product, Order order, int amount, PaymentStatus status) { + 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.amount = validateAmount(amount); + this.totalAmount = validateAmount(totalAmount); + this.discountAmount = validateAmount(discountAmount); this.status = requireNonNullElse(status, PaymentStatus.READY); } @@ -100,20 +101,46 @@ private int validateAmount(int 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(Coupon coupon, Long couponWalletId) { - this.coupon = coupon; this.couponWalletId = couponWalletId; - this.amount = max(MIN_AMOUNT, this.amount - coupon.getPoint()); + this.discountAmount = coupon.getPoint(); + this.totalAmount = Math.max(MIN_AMOUNT, this.totalAmount - coupon.getPoint()); } public void request(String orderId) { this.order.updateId(orderId); this.requestedAt = LocalDateTime.now(); } + + public void confirm(String paymentKey, LocalDateTime approvedAt) { + this.paymentKey = paymentKey; + this.approvedAt = approvedAt; + 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/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/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/ConfirmTossPaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java new file mode 100644 index 00000000..3527f148 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.payment; + +import lombok.Builder; + +@Builder +public record ConfirmTossPaymentRequest( + String paymentKey, + String orderId, + 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..ee32917a --- /dev/null +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java @@ -0,0 +1,21 @@ +package com.moabam.api.dto.payment; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.moabam.api.domain.payment.PaymentStatus; + +import lombok.Builder; + +@Builder +@JsonIgnoreProperties(ignoreUnknown = true) +public record ConfirmTossPaymentResponse( + String paymentKey, + String orderId, + String orderName, + PaymentStatus status, + int totalAmount, + LocalDateTime approvedAt +) { + +} diff --git a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java index a14a00c7..6e7eda86 100644 --- a/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java +++ b/src/main/java/com/moabam/api/dto/product/PurchaseProductRequest.java @@ -1,6 +1,6 @@ package com.moabam.api.dto.product; -import javax.annotation.Nullable; +import jakarta.annotation.Nullable; public record PurchaseProductRequest( @Nullable Long couponWalletId diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java new file mode 100644 index 00000000..146036eb --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java @@ -0,0 +1,18 @@ +package com.moabam.api.infrastructure.payment; + +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class TossPaymentMapper { + + public static ConfirmTossPaymentRequest toConfirmRequest(String paymentKey, String orderId, int amount) { + return ConfirmTossPaymentRequest.builder() + .paymentKey(paymentKey) + .orderId(orderId) + .amount(amount) + .build(); + } +} 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..2389746e --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -0,0 +1,50 @@ +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.ConfirmTossPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; +import com.moabam.global.config.TossPaymentConfig; +import com.moabam.global.error.exception.MoabamException; +import com.moabam.global.error.model.ErrorResponse; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import reactor.core.publisher.Mono; + +@Service +@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.setBearerAuth(Base64.getEncoder().encodeToString(config.secretKey().getBytes())); + }) + .build(); + } + + public ConfirmTossPaymentResponse confirm(ConfirmTossPaymentRequest request) { + return webClient.post() + .uri("/v1/payments/confirm") + .body(BodyInserters.fromValue(request)) + .retrieve() + .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(ErrorResponse.class) + .flatMap(error -> Mono.error(new MoabamException(error.message())))) + .bodyToMono(ConfirmTossPaymentResponse.class) + .block(); + } +} diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java index 715fac64..e294f6fc 100644 --- a/src/main/java/com/moabam/api/presentation/PaymentController.java +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -9,6 +9,7 @@ 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.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; @@ -25,9 +26,14 @@ public class PaymentController { @PostMapping("/{paymentId}") @ResponseStatus(HttpStatus.OK) - public void requestPayment(@Auth AuthMember member, - @PathVariable Long paymentId, + 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) { + paymentService.confirm(member.id(), request); + } } 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/resources/config b/src/main/resources/config index 2e460460..ea25d857 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 +Subproject commit ea25d85744c2e6fcedbdb66b34c08837d382814d diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 60f27e8c..4b9cc040 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 183
+Content-Length: 175
 Host: localhost:8080
 
 {
@@ -540,7 +540,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 215 +Content-Length: 205 { "id" : 22, @@ -571,7 +571,7 @@

요청

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index a06f23b3..d56f528e 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index f5599a33..c56e95bf 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -1,6 +1,8 @@ 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.*; @@ -14,9 +16,16 @@ 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.product.repository.ProductRepository; +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.MoabamException; import com.moabam.global.error.exception.NotFoundException; @ExtendWith(MockitoExtension.class) @@ -26,18 +35,27 @@ class PaymentServiceTest { PaymentService paymentService; @Mock - ProductRepository productRepository; + BugService bugService; + + @Mock + CouponService couponService; + + @Mock + TossPaymentService tossPaymentService; @Mock PaymentRepository paymentRepository; + @Mock + PaymentSearchRepository paymentSearchRepository; + @DisplayName("결제를 요청한다.") @Nested - class RequestPayment { + class Request { @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") @Test - void payment_not_found_exception() { + void not_found_exception() { // given Long memberId = 1L; Long paymentId = 1L; @@ -50,4 +68,60 @@ void payment_not_found_exception() { .hasMessage("존재하지 않는 결제 정보입니다."); } } + + @DisplayName("결제를 승인한다.") + @Nested + class Confirm { + + @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void not_found_exception() { + // given + Long memberId = 1L; + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> paymentService.confirm(memberId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); + } + + @DisplayName("쿠폰을 적용한 경우 쿠폰을 차감한 후 벌레를 충전한다.") + @Test + void use_coupon_success() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); + + // when + paymentService.confirm(memberId, request); + + // then + verify(couponService, times(1)).use(memberId, couponWalletId); + verify(bugService, times(1)).charge(memberId, payment.getProduct()); + } + + @DisplayName("실패한다.") + @Test + void fail() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(any())).willThrow(MoabamException.class); + + // when + paymentService.confirm(memberId, request); + + // then + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); + } + } } diff --git a/src/test/java/com/moabam/api/domain/bug/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java index 7e1981f0..96609913 100644 --- a/src/test/java/com/moabam/api/domain/bug/BugTest.java +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -68,9 +68,9 @@ void increase_bug_success() { Bug bug = bug(); // when - bug.increaseBug(BugType.MORNING, 5); - bug.increaseBug(BugType.NIGHT, 5); - bug.increaseBug(BugType.GOLDEN, 5); + bug.increase(BugType.MORNING, 5); + bug.increase(BugType.NIGHT, 5); + bug.increase(BugType.GOLDEN, 5); // then assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG + 5); diff --git a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java index 8a2dfffb..b9ca3d9c 100644 --- a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -21,7 +21,7 @@ void validate_amount_exception() { .memberId(1L) .product(bugProduct()) .order(order(bugProduct())) - .amount(-1000); + .totalAmount(-1000); assertThatThrownBy(paymentBuilder::build) .isInstanceOf(BadRequestException.class) @@ -44,8 +44,8 @@ void success() { payment.applyCoupon(coupon, couponWalletId); // then - assertThat(payment.getAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); - assertThat(payment.getCoupon()).isEqualTo(coupon); + assertThat(payment.getTotalAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); + assertThat(payment.getDiscountAmount()).isEqualTo(coupon.getPoint()); assertThat(payment.getCouponWalletId()).isEqualTo(couponWalletId); } @@ -61,7 +61,7 @@ void discount_amount_greater() { payment.applyCoupon(coupon, couponWalletId); // then - assertThat(payment.getAmount()).isZero(); + assertThat(payment.getTotalAmount()).isZero(); } } 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..e9a57d0e --- /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.ConfirmTossPaymentRequest; +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 + ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + 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 + ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + 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/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index 536b4ab4..ee2080b9 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -1,8 +1,11 @@ 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.*; @@ -12,19 +15,25 @@ 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.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.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; @@ -39,6 +48,12 @@ class PaymentControllerTest extends WithoutFilterSupporter { @Autowired ObjectMapper objectMapper; + @MockBean + MemberService memberService; + + @MockBean + TossPaymentService tossPaymentService; + @Autowired PaymentRepository paymentRepository; @@ -86,4 +101,53 @@ void bad_request_body_exception(String orderId) throws Exception { .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(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); + given(memberService.getById(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()); + } + } } diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java index a47080ae..a46e4b7b 100644 --- a/src/test/java/com/moabam/support/fixture/PaymentFixture.java +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -1,19 +1,41 @@ package com.moabam.support.fixture; +import static com.moabam.support.fixture.ProductFixture.*; + +import java.time.LocalDateTime; + +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.payment.PaymentStatus; import com.moabam.api.domain.product.Product; +import com.moabam.api.dto.payment.ConfirmPaymentRequest; +import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +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)) - .amount(product.getPrice()) + .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(); } @@ -22,4 +44,31 @@ public static Order order(Product product) { .name(product.getName()) .build(); } + + public static ConfirmPaymentRequest confirmPaymentRequest() { + return ConfirmPaymentRequest.builder() + .paymentKey(PAYMENT_KEY) + .orderId(ORDER_ID) + .amount(AMOUNT) + .build(); + } + + public static ConfirmTossPaymentRequest confirmTossPaymentRequest() { + return ConfirmTossPaymentRequest.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) + .status(PaymentStatus.DONE) + .totalAmount(AMOUNT) + .approvedAt(LocalDateTime.of(2023, 1, 1, 1, 1)) + .build(); + } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 3497c521..32bd980a 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -62,3 +62,9 @@ token: secret-key: testestestestestestestestestesttestestestestestestestestestest allow: "" + +# Payment +payment: + toss: + base-url: "https://api.tosspayments.com" + secret-key: "test_sk_4yKeq5bgrpWk4XYdDoBxVGX0lzW6:" From cff25860f4728ca9edd9129a5d1dee63dbfcfeea Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Mon, 27 Nov 2023 14:51:40 +0900 Subject: [PATCH 087/185] =?UTF-8?q?feat:=20=EB=B2=8C=EB=A0=88=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#155)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 벌레 내역 조회 API 구현 * refactor: 결제 테이블 coupon_id 컬럼을 discount_amount로 변경 * test: 벌레 내역 조회 컨트롤러 통합 테스트 * fix: 테스트 오류 수정 * chore: 사용하지 않는 메서드 제거 * refactor: Response 분리 * style: 줄바꿈 제거 --- .../moabam/api/application/bug/BugMapper.java | 37 +++++++++-- .../api/application/bug/BugService.java | 10 +++ .../api/application/coupon/CouponService.java | 2 - .../application/payment/PaymentMapper.java | 14 ++++ .../com/moabam/api/domain/bug/BugHistory.java | 11 +++- .../BugHistorySearchRepository.java | 33 +++++----- .../repository/CouponWalletRepository.java | 3 + .../api/dto/bug/BugHistoryItemResponse.java | 22 +++++++ .../api/dto/bug/BugHistoryResponse.java | 12 ++++ .../api/dto/bug/BugHistoryWithPayment.java | 21 ++++++ .../api/dto/payment/PaymentResponse.java | 13 ++++ .../api/presentation/BugController.java | 7 ++ .../moabam/global/common/util/DateUtils.java | 17 +++++ .../com/moabam/api/domain/bug/BugTest.java | 1 - .../api/presentation/BugControllerTest.java | 65 +++++++++++++++++++ .../moabam/support/fixture/BugFixture.java | 26 ++++++++ 16 files changed, 267 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/bug/BugHistoryItemResponse.java create mode 100644 src/main/java/com/moabam/api/dto/bug/BugHistoryResponse.java create mode 100644 src/main/java/com/moabam/api/dto/bug/BugHistoryWithPayment.java create mode 100644 src/main/java/com/moabam/api/dto/payment/PaymentResponse.java create mode 100644 src/main/java/com/moabam/global/common/util/DateUtils.java diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index 9f217971..cf40d215 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -1,10 +1,18 @@ 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; @@ -12,6 +20,15 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class BugMapper { + 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 BugResponse toBugResponse(Bug bug) { return BugResponse.builder() .morningBug(bug.getMorningBug()) @@ -20,12 +37,20 @@ public static BugResponse toBugResponse(Bug bug) { .build(); } - public static BugHistory toUseBugHistory(Long memberId, BugType bugType, int quantity) { - return BugHistory.builder() - .memberId(memberId) - .bugType(bugType) - .actionType(BugActionType.USE) - .quantity(quantity) + 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(); } diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 18d2a7a2..3e6c1295 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -15,11 +15,14 @@ import com.moabam.api.application.product.ProductMapper; import com.moabam.api.domain.bug.Bug; import com.moabam.api.domain.bug.repository.BugHistoryRepository; +import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; import com.moabam.api.domain.coupon.Coupon; 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; @@ -36,6 +39,7 @@ public class BugService { private final MemberService memberService; private final CouponService couponService; private final BugHistoryRepository bugHistoryRepository; + private final BugHistorySearchRepository bugHistorySearchRepository; private final ProductRepository productRepository; private final PaymentRepository paymentRepository; @@ -45,6 +49,12 @@ public BugResponse getBug(Long 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); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index ec96458a..598e0c27 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -10,7 +10,6 @@ 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; @@ -35,7 +34,6 @@ public class CouponService { private final CouponManageService couponManageService; private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; - private final CouponWalletRepository couponWalletRepository; private final CouponWalletSearchRepository couponWalletSearchRepository; @Transactional diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java index 3495adad..322ce92a 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -1,8 +1,11 @@ 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.PaymentResponse; import lombok.AccessLevel; import lombok.NoArgsConstructor; @@ -22,4 +25,15 @@ public static Payment toPayment(Long memberId, Product product) { .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); + } } diff --git a/src/main/java/com/moabam/api/domain/bug/BugHistory.java b/src/main/java/com/moabam/api/domain/bug/BugHistory.java index 072a16ec..9c08f4b4 100644 --- a/src/main/java/com/moabam/api/domain/bug/BugHistory.java +++ b/src/main/java/com/moabam/api/domain/bug/BugHistory.java @@ -3,6 +3,7 @@ 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; @@ -10,9 +11,12 @@ 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; @@ -33,6 +37,10 @@ public class BugHistory extends BaseTimeEntity { @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; @@ -45,8 +53,9 @@ public class BugHistory extends BaseTimeEntity { private int quantity; @Builder - private BugHistory(Long memberId, BugType bugType, BugActionType actionType, int quantity) { + 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); 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 index 82a9ae61..8c5f569a 100644 --- a/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java +++ b/src/main/java/com/moabam/api/domain/bug/repository/BugHistorySearchRepository.java @@ -1,16 +1,14 @@ 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.time.LocalDateTime; import java.util.List; import org.springframework.stereotype.Repository; -import com.moabam.api.domain.bug.BugActionType; -import com.moabam.api.domain.bug.BugHistory; -import com.moabam.global.common.util.DynamicQuery; -import com.querydsl.core.types.dsl.BooleanExpression; +import com.moabam.api.dto.bug.BugHistoryWithPayment; +import com.querydsl.core.types.Projections; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -21,19 +19,20 @@ public class BugHistorySearchRepository { private final JPAQueryFactory jpaQueryFactory; - public List find(Long memberId, BugActionType actionType, LocalDateTime dateTime) { - return jpaQueryFactory.selectFrom(bugHistory) - .where( - DynamicQuery.generateEq(memberId, bugHistory.memberId::eq), - DynamicQuery.generateEq(actionType, bugHistory.actionType::eq), - DynamicQuery.generateEq(dateTime, this::equalDate) + 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(); } - - private BooleanExpression equalDate(LocalDateTime dateTime) { - return bugHistory.createdAt.year().eq(dateTime.getYear()) - .and(bugHistory.createdAt.month().eq(dateTime.getMonthValue())) - .and(bugHistory.createdAt.dayOfMonth().eq(dateTime.getDayOfMonth())); - } } 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 index 626512dc..48da8ca1 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponWalletRepository.java @@ -1,9 +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/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/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/presentation/BugController.java b/src/main/java/com/moabam/api/presentation/BugController.java index 41a56009..51246958 100644 --- a/src/main/java/com/moabam/api/presentation/BugController.java +++ b/src/main/java/com/moabam/api/presentation/BugController.java @@ -10,6 +10,7 @@ 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; @@ -33,6 +34,12 @@ 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() { 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..38fa97b4 --- /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"); + + public static String format(LocalDateTime dateTime) { + return dateTime.format(formatter); + } +} diff --git a/src/test/java/com/moabam/api/domain/bug/BugTest.java b/src/test/java/com/moabam/api/domain/bug/BugTest.java index 96609913..4fe62d0d 100644 --- a/src/test/java/com/moabam/api/domain/bug/BugTest.java +++ b/src/test/java/com/moabam/api/domain/bug/BugTest.java @@ -75,6 +75,5 @@ void increase_bug_success() { // then assertThat(bug.getMorningBug()).isEqualTo(MORNING_BUG + 5); assertThat(bug.getNightBug()).isEqualTo(NIGHT_BUG + 5); - assertThat(bug.getGoldenBug()).isEqualTo(GOLDEN_BUG + 5); } } diff --git a/src/test/java/com/moabam/api/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 34025882..0a8dcb3e 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -3,6 +3,7 @@ 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.*; @@ -15,6 +16,7 @@ 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; @@ -27,10 +29,15 @@ 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; @@ -61,6 +68,9 @@ class BugControllerTest extends WithoutFilterSupporter { @Autowired ProductRepository productRepository; + @Autowired + PaymentRepository paymentRepository; + @DisplayName("벌레를 조회한다.") @WithMember @Test @@ -82,6 +92,61 @@ void get_bug_success() throws Exception { 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 { diff --git a/src/test/java/com/moabam/support/fixture/BugFixture.java b/src/test/java/com/moabam/support/fixture/BugFixture.java index a2584bcd..a6578d40 100644 --- a/src/test/java/com/moabam/support/fixture/BugFixture.java +++ b/src/test/java/com/moabam/support/fixture/BugFixture.java @@ -1,12 +1,19 @@ 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() @@ -15,4 +22,23 @@ public static Bug bug() { .goldenBug(GOLDEN_BUG) .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(); + } } From 92b1592549bc44366f56305469d956bdafb7545b Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Mon, 27 Nov 2023 15:18:01 +0900 Subject: [PATCH 088/185] =?UTF-8?q?feat:=20=EB=B0=A9=20=EC=9D=B8=EC=A6=9D,?= =?UTF-8?q?=20=EC=9E=85=EC=9E=A5=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20(#157)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ClockHolder LocalDate 추가 * refactor: RoomService 리팩토링 * refactor: SearchService 리팩토링 * refactor: 방 입장, 퇴장 리팩토링 * refactor: CertifiactionService 리팩토링 * refactor: RoomController 리팩토링 * test: InventorySearchRepository 테스트 추가 * chore: 테스트 코드 In-memory H2에서 MySQL로 변경 * feat: CertifyRoom Transaction 분리, 비관적 락 적용 * feat: 방 입장 낙관적 락 적용 * refactor: MySQL 변경으로 일부 테스트 수정 * test: 방 인증, 입장 동시성 테스트 작성 * test: 방장 위임 테스트 작성 * fix: 방 입장 낙관적 락 -> 비관적 락으로 변경 * refactor: Room version 삭제 * fix: 코드 수정 * feat: Image Type 추가 --------- Co-authored-by: Dev Uni --- .github/workflows/ci.yml | 4 + .../room/CertificationService.java | 20 ++- .../api/application/room/RoomService.java | 16 +-- .../room/mapper/CertificationsMapper.java | 12 ++ .../moabam/api/domain/image/ImageName.java | 10 +- .../moabam/api/domain/image/ImageResizer.java | 1 + .../moabam/api/domain/image/ImageType.java | 1 + .../java/com/moabam/api/domain/room/Room.java | 4 - .../CertificationsSearchRepository.java | 2 + .../room/repository/RoomRepository.java | 7 + .../api/dto/room/CertifiedMemberInfo.java | 19 +++ .../api/presentation/RoomController.java | 4 +- .../global/common/util/GlobalConstant.java | 1 + .../{ => image}/ImageServiceTest.java | 3 +- .../CertificationServiceConcurrencyTest.java | 128 ++++++++++++++++++ .../room/CertificationServiceTest.java | 70 ++++++---- .../room/RoomServiceConcurrencyTest.java | 111 +++++++++++++++ .../CouponSearchRepositoryTest.java | 3 + .../InventorySearchRepositoryTest.java | 10 +- .../domain/member/MemberRepositoryTest.java | 12 +- .../ParticipantSearchRepositoryTest.java | 3 + .../api/presentation/RoomControllerTest.java | 48 +++++-- .../annotation/QuerydslRepositoryTest.java | 3 + src/test/resources/application.yml | 9 ++ 24 files changed, 428 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/room/CertifiedMemberInfo.java rename src/test/java/com/moabam/api/application/{ => image}/ImageServiceTest.java (94%) create mode 100644 src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java create mode 100644 src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9e0f202..6f94d5a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: - 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: diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index 4d320147..3b40a8f0 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -29,6 +29,7 @@ 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; @@ -53,7 +54,7 @@ public class CertificationService { private final ClockHolder clockHolder; @Transactional - public void certifyRoom(Long memberId, Long roomId, List imageUrls) { + 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)); @@ -63,22 +64,31 @@ public void certifyRoom(Long memberId, Long roomId, List imageUrls) { case MORNING -> BugType.MORNING; case NIGHT -> BugType.NIGHT; }; - int roomLevel = room.getLevel(); 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(roomId, today); + certificationsSearchRepository.findDailyRoomCertification(room.getId(), date); if (dailyRoomCertification.isEmpty()) { - certifyRoomIfAvailable(roomId, today, room, bugType, roomLevel); + certifyRoomIfAvailable(room.getId(), date, room, bugType, room.getLevel()); return; } - member.getBug().increase(bugType, roomLevel); + member.getBug().increase(bugType, room.getLevel()); } public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 50592ead..fab05eed 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -1,14 +1,7 @@ package com.moabam.api.application.room; -import static com.moabam.api.domain.room.RoomType.MORNING; -import static com.moabam.api.domain.room.RoomType.NIGHT; -import static com.moabam.global.error.model.ErrorMessage.MEMBER_ROOM_EXCEED; -import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.ROOM_EXIT_MANAGER_FAIL; -import static com.moabam.global.error.model.ErrorMessage.ROOM_MAX_USER_REACHED; -import static com.moabam.global.error.model.ErrorMessage.ROOM_MODIFY_UNAUTHORIZED_REQUEST; -import static com.moabam.global.error.model.ErrorMessage.ROOM_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.WRONG_ROOM_PASSWORD; +import static com.moabam.api.domain.room.RoomType.*; +import static com.moabam.global.error.model.ErrorMessage.*; import java.util.List; @@ -37,9 +30,11 @@ import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Service @RequiredArgsConstructor +@Slf4j @Transactional(readOnly = true) public class RoomService { @@ -90,7 +85,8 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR @Transactional public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequest) { - Room room = roomRepository.findById(roomId).orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + Room room = roomRepository.findWithPessimisticLockById(roomId).orElseThrow( + () -> new NotFoundException(ROOM_NOT_FOUND)); validateRoomEnter(memberId, enterRoomRequest.password(), room); Member member = memberService.getById(memberId); 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 index 060e3478..797f18db 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/CertificationsMapper.java @@ -3,14 +3,17 @@ 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; @@ -72,4 +75,13 @@ public static Certification toCertification(Routine routine, Long memberId, Stri .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/domain/image/ImageName.java b/src/main/java/com/moabam/api/domain/image/ImageName.java index 64082d54..6f2a34db 100644 --- a/src/main/java/com/moabam/api/domain/image/ImageName.java +++ b/src/main/java/com/moabam/api/domain/image/ImageName.java @@ -17,15 +17,19 @@ 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()); - case PROFILE_IMAGE -> new ImageName(PROFILE_IMAGE + file.getName() + "_" + UUID.randomUUID()); - case DEFAULT -> new ImageName(DEFAULT + file.getName()); + 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 index 79e44a9e..5a7d65ba 100644 --- a/src/main/java/com/moabam/api/domain/image/ImageResizer.java +++ b/src/main/java/com/moabam/api/domain/image/ImageResizer.java @@ -61,6 +61,7 @@ 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; }; diff --git a/src/main/java/com/moabam/api/domain/image/ImageType.java b/src/main/java/com/moabam/api/domain/image/ImageType.java index 80cbeba3..dc4dc358 100644 --- a/src/main/java/com/moabam/api/domain/image/ImageType.java +++ b/src/main/java/com/moabam/api/domain/image/ImageType.java @@ -4,5 +4,6 @@ public enum ImageType { PROFILE_IMAGE, CERTIFICATION, + BIRD_SKIN, DEFAULT } diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java index a1d93c20..8a3d3dbe 100644 --- a/src/main/java/com/moabam/api/domain/room/Room.java +++ b/src/main/java/com/moabam/api/domain/room/Room.java @@ -128,10 +128,6 @@ public void changeMaxCount(int maxUserCount) { public void increaseCurrentUserCount() { this.currentUserCount += 1; - - if (this.currentUserCount > this.maxUserCount) { - throw new BadRequestException(ROOM_MAX_USER_REACHED); - } } public void decreaseCurrentUserCount() { 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 index 67cd0b86..8563245c 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java @@ -17,6 +17,7 @@ import com.moabam.api.domain.room.DailyRoomCertification; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; import lombok.RequiredArgsConstructor; @Repository @@ -67,6 +68,7 @@ public Optional findDailyRoomCertification(Long roomId, dailyRoomCertification.roomId.eq(roomId), dailyRoomCertification.certifiedAt.eq(date) ) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) .fetchOne()); } 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 index e69b7c7c..494942fc 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java @@ -1,15 +1,22 @@ 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 findWithPessimisticLockById(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% " 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/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index c3d720fd..55146a8e 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -23,6 +23,7 @@ import com.moabam.api.application.room.SearchService; import com.moabam.api.domain.image.ImageType; import com.moabam.api.domain.room.RoomType; +import com.moabam.api.dto.room.CertifiedMemberInfo; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.GetAllRoomsResponse; @@ -98,7 +99,8 @@ public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, @PathVari public void certifyRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, @RequestPart List multipartFiles) { List imageUrls = imageService.uploadImages(multipartFiles, ImageType.CERTIFICATION); - certificationService.certifyRoom(authMember.id(), roomId, imageUrls); + CertifiedMemberInfo info = certificationService.getCertifiedMemberInfo(authMember.id(), roomId, imageUrls); + certificationService.certifyRoom(info); } @PutMapping("/{roomId}/members/{memberId}/mandate") diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index ec45d2ec..a9f79ff3 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -19,4 +19,5 @@ public class GlobalConstant { public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; public static final int DEFAULT_SKIN_SIZE = 2; + public static final String IMAGE_EXTENSION = ".png"; } diff --git a/src/test/java/com/moabam/api/application/ImageServiceTest.java b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java similarity index 94% rename from src/test/java/com/moabam/api/application/ImageServiceTest.java rename to src/test/java/com/moabam/api/application/image/ImageServiceTest.java index e7b7e0ca..d8cdcc79 100644 --- a/src/test/java/com/moabam/api/application/ImageServiceTest.java +++ b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java @@ -1,4 +1,4 @@ -package com.moabam.api.application; +package com.moabam.api.application.image; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -15,7 +15,6 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.web.multipart.MultipartFile; -import com.moabam.api.application.image.ImageService; import com.moabam.api.domain.image.ImageType; import com.moabam.api.domain.image.ResizedImage; import com.moabam.api.infrastructure.s3.S3Manager; 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..fd1fdc1e --- /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", "닉네임1"); + Member member2 = MemberFixture.member("1234", "닉네임2"); + Member member3 = MemberFixture.member("5678", "닉네임3"); + Member member4 = MemberFixture.member("3333", "닉네임4"); + Member member5 = MemberFixture.member("5555", "닉네임5"); + + 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 index ee3bacb1..7654f782 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -1,10 +1,8 @@ package com.moabam.api.application.room; -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.lenient; -import static org.mockito.BDDMockito.spy; +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; @@ -23,6 +21,8 @@ import com.moabam.api.application.image.ImageService; 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; @@ -37,6 +37,7 @@ 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; @@ -111,15 +112,16 @@ void init() { room.levelUp(); } - @DisplayName("이미 인증되어 있는 방에서 루틴 인증 성공") + @DisplayName("방 인증 전 개인 인증 후 정보 불러오기 성공") @Test - void already_certified_room_routine_success() { + void get_certified_member_info_success() { // given List routines = RoomFixture.routines(room); - DailyRoomCertification dailyRoomCertification = RoomFixture.dailyRoomCertification(roomId, today); - List uploadImages = new ArrayList<>(); - uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); - uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); + 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(9).withMinute(58)); given(clockHolder.date()).willReturn(today); @@ -127,42 +129,59 @@ void already_certified_room_routine_success() { given(memberService.getById(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(memberId, roomId, uploadImages); + certificationService.certifyRoom(certifyInfo); // then assertThat(member1.getBug().getMorningBug()).isEqualTo(12); - assertThat(member1.getTotalCertifyCount()).isEqualTo(1); } @DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공") @Test void not_certified_room_routine_success() { // given - List routines = RoomFixture.routines(room); List dailyMemberCertifications = RoomFixture.dailyMemberCertifications(roomId, participant); - List uploadImages = new ArrayList<>(); - uploadImages.add("https://image.moabam.com/certifications/20231108/1_asdfsdfxcv-4815vcx-asfd"); - uploadImages.add("https://image.moabam.com/certifications/20231108/2_asdfsdfxcv-4815vcx-asfd"); - given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); given(clockHolder.date()).willReturn(today); - given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); - given(memberService.getById(memberId)).willReturn(member1); - given(routineRepository.findById(1L)).willReturn(Optional.of(routines.get(0))); - given(routineRepository.findById(2L)).willReturn(Optional.of(routines.get(1))); - given(certificationsSearchRepository.findDailyRoomCertification(roomId, today)) - .willReturn(Optional.empty()); 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(memberId, roomId, uploadImages); + certificationService.certifyRoom(certifyInfo); // then assertThat(member1.getBug().getMorningBug()).isEqualTo(12); @@ -173,5 +192,4 @@ void not_certified_room_routine_success() { 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..53a190f9 --- /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", "닉네임1"); + Member member2 = MemberFixture.member("qwfe", "닉네임2"); + Member member3 = MemberFixture.member("qff", "닉네임3"); + 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), "test"); + 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.findParticipantsByRoomId(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/domain/coupon/repository/CouponSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java index 67fdf033..41d5cf78 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponSearchRepositoryTest.java @@ -9,6 +9,8 @@ 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; @@ -18,6 +20,7 @@ import com.moabam.support.fixture.CouponFixture; @DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) @Import({JpaConfig.class, CouponSearchRepository.class}) class CouponSearchRepositoryTest { 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 index 2fdd57e9..1e6a3299 100644 --- a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -85,7 +85,7 @@ void empty_success() { @Test void find_one_success() { // given - Member member = memberRepository.save(member()); + Member member = memberRepository.save(member("999", "test")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); @@ -100,7 +100,7 @@ void find_one_success() { @Test void find_default_success() { // given - Member member = memberRepository.save(member()); + Member member = memberRepository.save(member("11314", "test")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); inventory.select(); @@ -116,8 +116,8 @@ void find_default_success() { @Test void find_all_default_type_night_success() { // given - Member member1 = memberRepository.save(member("1", "회원1")); - Member member2 = memberRepository.save(member("2", "회원2")); + Member member1 = memberRepository.save(member("625", "회원1")); + Member member2 = memberRepository.save(member("255", "회원2")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory1 = inventoryRepository.save(inventory(member1.getId(), item)); Inventory inventory2 = inventoryRepository.save(inventory(member2.getId(), item)); @@ -141,7 +141,7 @@ class FindDefaultBird { @Test void bird_find_success() { // given - Member member = MemberFixture.member(); + Member member = MemberFixture.member("fffdd", "test"); member.exitRoom(RoomType.MORNING); memberRepository.save(member); diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index ff6d4403..b21530fc 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -49,7 +49,7 @@ class MemberRepositoryTest { @Test void test() { // given - Member member = MemberFixture.member(); + Member member = MemberFixture.member("313", "test"); memberRepository.save(member); // when @@ -67,7 +67,7 @@ class FindMemberTest { @Test void room_exist_and_manager_error() { // given - Member member = MemberFixture.member(); + Member member = MemberFixture.member("1111", "test"); memberRepository.save(member); Room room = RoomFixture.room(); @@ -93,7 +93,7 @@ void room_exist_and_not_manager_success() { room.changeManagerNickname("test"); roomRepository.save(room); - Member member = MemberFixture.member(); + Member member = MemberFixture.member("44", "test"); member.changeNickName("not"); memberRepository.save(member); @@ -114,7 +114,7 @@ class FindMemberInfo { @Test void member_not_found() { // Given - List memberInfos = memberSearchRepository.findMemberAndBadges(1L, false); + List memberInfos = memberSearchRepository.findMemberAndBadges(999L, false); // When + Then assertThat(memberInfos).isEmpty(); @@ -124,7 +124,7 @@ void member_not_found() { @Test void search_info_success() { // given - Member member = MemberFixture.member(); + Member member = MemberFixture.member("hhhh", "test"); member.enterRoom(RoomType.MORNING); memberRepository.save(member); @@ -149,7 +149,7 @@ void search_info_success() { @Test void no_badges_search_success() { // given - Member member = MemberFixture.member(); + Member member = MemberFixture.member("ttttt", "test"); member.enterRoom(RoomType.MORNING); memberRepository.save(member); 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 index c3007a48..82937bf5 100644 --- a/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/room/repository/ParticipantSearchRepositoryTest.java @@ -8,6 +8,8 @@ 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; @@ -16,6 +18,7 @@ import com.moabam.global.config.JpaConfig; @DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) @Import({JpaConfig.class, ParticipantSearchRepository.class}) class ParticipantSearchRepositoryTest { diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 1e812e7b..580005b7 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -1,16 +1,11 @@ package com.moabam.api.presentation; -import static com.moabam.api.domain.room.RoomType.MORNING; -import static com.moabam.api.domain.room.RoomType.NIGHT; -import static org.assertj.core.api.Assertions.assertThat; -import static org.springframework.http.MediaType.APPLICATION_JSON; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; -import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +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; @@ -892,6 +887,37 @@ void deport_member_success() throws Exception { assertThat(participantSearchRepository.findOne(member.getId(), room.getId())).isEmpty(); } + @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 diff --git a/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java b/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java index 254439c4..0f31b471 100644 --- a/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java +++ b/src/test/java/com/moabam/support/annotation/QuerydslRepositoryTest.java @@ -5,6 +5,8 @@ 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; @@ -14,6 +16,7 @@ @Retention(RetentionPolicy.RUNTIME) @Import(TestQuerydslConfig.class) @DataJpaTest +@AutoConfigureTestDatabase(replace = Replace.NONE) public @interface QuerydslRepositoryTest { } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 32bd980a..8090fb64 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -9,11 +9,20 @@ spring: 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: From 6bba643b98fcd5bbde41de8c2a9c00770fdb9dab Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 27 Nov 2023 15:25:59 +0900 Subject: [PATCH 089/185] =?UTF-8?q?hotfix:=20develop-cd=20docker=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 89a94528..60bad952 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -103,6 +103,10 @@ jobs: - 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: From da36a04f303cba74904c20197f006e49a51829fe Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 27 Nov 2023 15:58:08 +0900 Subject: [PATCH 090/185] =?UTF-8?q?feat:=20=EB=B0=A9/=ED=9A=8C=EC=9B=90/?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8B=A0=EA=B3=A0=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#158)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: 삭제된 회원 조회 테스트 추가 * refactor: 회원 조회 변경 * feat: 신고 기능 추가 및 테스트 코드 추가 * refactor: 신고 기능 로직 수정 및 테스트 코드 추가 * feat: 신고 api 기능 추가 및 테스트 코드 추가 * fix: 통합 테스트간 데이터 중복 및 index 문제 해결 * refactor: CsvSource null 부분 변경 --- .../api/application/bug/BugService.java | 4 +- .../api/application/item/ItemService.java | 2 +- .../api/application/member/MemberService.java | 4 +- .../api/application/report/ReportMapper.java | 23 +++ .../api/application/report/ReportService.java | 60 +++++++ .../room/CertificationService.java | 7 +- .../api/application/room/RoomService.java | 17 +- .../com/moabam/api/domain/report/Report.java | 56 ++++++ .../report/repository/ReportRepository.java | 9 + .../moabam/api/dto/report/ReportRequest.java | 12 ++ .../api/presentation/ReportController.java | 30 ++++ .../global/error/model/ErrorMessage.java | 3 + src/main/resources/config | 2 +- .../api/application/bug/BugServiceTest.java | 2 +- .../api/application/item/ItemServiceTest.java | 4 +- .../application/report/ReportServiceTest.java | 93 ++++++++++ .../room/CertificationServiceTest.java | 2 +- .../api/application/room/RoomServiceTest.java | 6 +- .../domain/member/MemberRepositoryTest.java | 38 ++++ .../api/presentation/BugControllerTest.java | 2 +- .../api/presentation/ItemControllerTest.java | 2 +- .../presentation/MemberControllerTest.java | 1 - .../presentation/PaymentControllerTest.java | 2 +- .../presentation/ReportControllerTest.java | 162 ++++++++++++++++++ .../support/common/ClearDataExtension.java | 15 ++ .../support/common/DataCleanResolver.java | 54 ++++++ .../common/WithoutFilterSupporter.java | 8 +- .../moabam/support/fixture/ReportFixture.java | 30 ++++ 28 files changed, 623 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/report/ReportMapper.java create mode 100644 src/main/java/com/moabam/api/application/report/ReportService.java create mode 100644 src/main/java/com/moabam/api/domain/report/Report.java create mode 100644 src/main/java/com/moabam/api/domain/report/repository/ReportRepository.java create mode 100644 src/main/java/com/moabam/api/dto/report/ReportRequest.java create mode 100644 src/main/java/com/moabam/api/presentation/ReportController.java create mode 100644 src/test/java/com/moabam/api/application/report/ReportServiceTest.java create mode 100644 src/test/java/com/moabam/api/presentation/ReportControllerTest.java create mode 100644 src/test/java/com/moabam/support/common/ClearDataExtension.java create mode 100644 src/test/java/com/moabam/support/common/DataCleanResolver.java create mode 100644 src/test/java/com/moabam/support/fixture/ReportFixture.java diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index 3e6c1295..d35e7549 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -44,7 +44,7 @@ public class BugService { private final PaymentRepository paymentRepository; public BugResponse getBug(Long memberId) { - Bug bug = memberService.getById(memberId).getBug(); + Bug bug = memberService.findMember(memberId).getBug(); return BugMapper.toBugResponse(bug); } @@ -77,7 +77,7 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, @Transactional public void charge(Long memberId, Product bugProduct) { - Bug bug = memberService.getById(memberId).getBug(); + Bug bug = memberService.findMember(memberId).getBug(); bug.charge(bugProduct.getQuantity()); bugHistoryRepository.save(BugMapper.toChargeBugHistory(memberId, bugProduct.getQuantity())); diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java index 3a024c2e..a0d5015f 100644 --- a/src/main/java/com/moabam/api/application/item/ItemService.java +++ b/src/main/java/com/moabam/api/application/item/ItemService.java @@ -49,7 +49,7 @@ public ItemsResponse getItems(Long memberId, ItemType type) { @Transactional public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request) { Item item = getItem(itemId); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); validateAlreadyPurchased(memberId, itemId); item.validatePurchasable(request.bugType(), member.getLevel()); diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index ce4ca41a..cbc5f451 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -41,8 +41,8 @@ public class MemberService { private final MemberSearchRepository memberSearchRepository; private final ClockHolder clockHolder; - public Member getById(Long memberId) { - return memberRepository.findById(memberId) + public Member findMember(Long memberId) { + return memberSearchRepository.findMember(memberId) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); } 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..f626383d --- /dev/null +++ b/src/main/java/com/moabam/api/application/report/ReportService.java @@ -0,0 +1,60 @@ +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.roomId(), reportRequest.certificationId()); + Report report = createReport(authMember.id(), reportRequest); + reportRepository.save(report); + } + + private Report createReport(Long reporterId, ReportRequest reportRequest) { + Member reportedMember = memberService.findMember(reportRequest.reportedId()); + + if (nonNull(reportRequest.certificationId())) { + Certification certification = certificationService.findCertification(reportRequest.certificationId()); + + return ReportMapper.toReport(reporterId, reportedMember.getId(), + null, certification, reportRequest.description()); + } + + Room room = roomService.findRoom(reportRequest.roomId()); + + return ReportMapper.toReport(reporterId, reportedMember.getId(), + room, null, reportRequest.description()); + } + + private void validateNoReportSubject(Long roomId, Long certificationId) { + if (isNull(roomId) && isNull(certificationId)) { + 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 index 3b40a8f0..f8e72e9d 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -59,7 +59,7 @@ public CertifiedMemberInfo getCertifiedMemberInfo(Long memberId, Long roomId, Li Participant participant = participantSearchRepository.findOne(memberId, roomId) .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); Room room = participant.getRoom(); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); BugType bugType = switch (room.getRoomType()) { case MORNING -> BugType.MORNING; case NIGHT -> BugType.NIGHT; @@ -100,6 +100,11 @@ public boolean existsRoomCertification(Long roomId, LocalDate date) { return dailyRoomCertificationRepository.existsByRoomIdAndCertifiedAt(roomId, date); } + 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 minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index fab05eed..d50c4658 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -52,7 +52,7 @@ public Long createRoom(Long memberId, String nickname, CreateRoomRequest createR validateEnteredRoomCount(memberId, room.getRoomType()); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); member.enterRoom(room.getRoomType()); participant.enableManager(); room.changeManagerNickname(nickname); @@ -89,7 +89,7 @@ public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequ () -> new NotFoundException(ROOM_NOT_FOUND)); validateRoomEnter(memberId, enterRoomRequest.password(), room); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); member.enterRoom(room.getRoomType()); room.increaseCurrentUserCount(); @@ -104,7 +104,7 @@ public void exitRoom(Long memberId, Long roomId) { validateRoomExit(participant, room); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); member.exitRoom(room.getRoomType()); participant.removeRoom(); @@ -126,7 +126,7 @@ public void mandateManager(Long managerId, Long roomId, Long memberId) { validateManagerAuthorization(managerParticipant); Room room = managerParticipant.getRoom(); - Member member = memberService.getById(memberParticipant.getMemberId()); + Member member = memberService.findMember(memberParticipant.getMemberId()); room.changeManagerNickname(member.getNickname()); managerParticipant.disableManager(); @@ -143,7 +143,7 @@ public void deportParticipant(Long managerId, Long roomId, Long memberId) { participantRepository.delete(memberParticipant); room.decreaseCurrentUserCount(); - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); member.exitRoom(room.getRoomType()); } @@ -153,6 +153,11 @@ public void validateRoomById(Long roomId) { } } + public Room findRoom(Long roomId) { + return roomRepository.findById(roomId) + .orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); + } + private Participant getParticipant(Long memberId, Long roomId) { return participantSearchRepository.findOne(memberId, roomId) .orElseThrow(() -> new NotFoundException(PARTICIPANT_NOT_FOUND)); @@ -176,7 +181,7 @@ private void validateRoomEnter(Long memberId, String requestPassword, Room room) } private void validateEnteredRoomCount(Long memberId, RoomType roomType) { - Member member = memberService.getById(memberId); + Member member = memberService.findMember(memberId); if (roomType.equals(MORNING) && member.getCurrentMorningCount() >= 3) { throw new BadRequestException(MEMBER_ROOM_EXCEED); 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/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/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/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 3cfa68f2..078d7fba 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -13,6 +13,8 @@ public enum ErrorMessage { NOT_FOUND_AVAILABLE_PORT("사용 가능한 포트를 찾을 수 없습니다. (10000 ~ 65535)"), ERROR_EXECUTING_EMBEDDED_REDIS("Embedded Redis 실행 중 오류가 발생했습니다."), + REPORT_REQUEST_ERROR("신고 요청하고자 하는 방이나 대상이 존재하지 않습니다."), + ROOM_NOT_FOUND("존재하지 않는 방 입니다."), ROOM_MAX_USER_COUNT_MODIFY_FAIL("잘못된 최대 인원수 설정입니다."), ROOM_MODIFY_UNAUTHORIZED_REQUEST("방장이 아닌 사용자는 방을 수정할 수 없습니다."), @@ -26,6 +28,7 @@ public enum ErrorMessage { ROUTINE_NOT_FOUND("루틴을 찾을 수 없습니다"), INVALID_REQUEST_URL("잘못된 URL 요청입니다."), INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), + CERTIFICATION_NOT_FOUND("인증 정보가 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), diff --git a/src/main/resources/config b/src/main/resources/config index ea25d857..2e460460 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit ea25d85744c2e6fcedbdb66b34c08837d382814d +Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index 1cde9be1..fa075dc6 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -59,7 +59,7 @@ void get_bug_success() { // given Long memberId = 1L; Member member = member(); - given(memberService.getById(memberId)).willReturn(member); + given(memberService.findMember(memberId)).willReturn(member); // when BugResponse response = bugService.getBug(memberId); diff --git a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java index c0f06cab..6abe441d 100644 --- a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -98,7 +98,7 @@ void success() { PurchaseItemRequest request = new PurchaseItemRequest(BugType.GOLDEN); Member member = member(); Item item = nightMageSkin(); - given(memberService.getById(memberId)).willReturn(member); + given(memberService.findMember(memberId)).willReturn(member); given(itemRepository.findById(itemId)).willReturn(Optional.of(item)); given(inventorySearchRepository.findOne(memberId, itemId)).willReturn(Optional.empty()); @@ -106,7 +106,7 @@ void success() { itemService.purchaseItem(memberId, itemId, request); // Then - verify(memberService).getById(memberId); + verify(memberService).findMember(memberId); verify(itemRepository).findById(itemId); verify(inventorySearchRepository).findOne(memberId, itemId); verify(inventoryRepository).save(any(Inventory.class)); 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..25b56d96 --- /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(5L, 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/CertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java index 7654f782..83c98f41 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -126,7 +126,7 @@ void get_certified_member_info_success() { given(clockHolder.times()).willReturn(LocalDateTime.now().withHour(9).withMinute(58)); given(clockHolder.date()).willReturn(today); given(participantSearchRepository.findOne(memberId, roomId)).willReturn(Optional.of(participant)); - given(memberService.getById(memberId)).willReturn(member1); + 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( diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java index b0038193..c2921883 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -67,7 +67,7 @@ void create_room_no_password_success() { Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); - given(memberService.getById(1L)).willReturn(member); + given(memberService.findMember(1L)).willReturn(member); // when Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); @@ -95,7 +95,7 @@ void create_room_with_password_success() { Room expectedRoom = RoomMapper.toRoomEntity(createRoomRequest); given(roomRepository.save(any(Room.class))).willReturn(expectedRoom); - given(memberService.getById(1L)).willReturn(member); + given(memberService.findMember(1L)).willReturn(member); // when Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); @@ -128,7 +128,7 @@ void room_manager_mandate_success() { Optional.of(memberParticipant)); given(participantSearchRepository.findOne(managerId, room.getId())).willReturn( Optional.of(managerParticipant)); - given(memberService.getById(2L)).willReturn(member); + given(memberService.findMember(2L)).willReturn(member); // when roomService.mandateManager(managerId, room.getId(), memberId); diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index b21530fc..8f600444 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -2,13 +2,16 @@ 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; @@ -27,6 +30,9 @@ import com.moabam.support.fixture.ParticipantFixture; import com.moabam.support.fixture.RoomFixture; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; + @QuerydslRepositoryTest class MemberRepositoryTest { @@ -45,6 +51,9 @@ class MemberRepositoryTest { @Autowired ParticipantRepository participantRepository; + @PersistenceContext + EntityManager entityManager; + @DisplayName("회원 생성 테스트") @Test void test() { @@ -163,4 +172,33 @@ void no_badges_search_success() { 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/presentation/BugControllerTest.java b/src/test/java/com/moabam/api/presentation/BugControllerTest.java index 0a8dcb3e..c7887beb 100644 --- a/src/test/java/com/moabam/api/presentation/BugControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/BugControllerTest.java @@ -78,7 +78,7 @@ void get_bug_success() throws Exception { // given Long memberId = getAuthMember().id(); BugResponse expected = BugMapper.toBugResponse(bug()); - given(memberService.getById(memberId)).willReturn(member()); + given(memberService.findMember(memberId)).willReturn(member()); // expected String content = mockMvc.perform(get("/bugs") diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 87168c4a..b637e8f7 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -114,7 +114,7 @@ void success() throws Exception { Long memberId = getAuthMember().id(); Item item = itemRepository.save(nightMageSkin()); PurchaseItemRequest request = new PurchaseItemRequest(BugType.NIGHT); - given(memberService.getById(memberId)).willReturn(member()); + given(memberService.findMember(memberId)).willReturn(member()); // expected mockMvc.perform(post("/items/{itemId}/purchase", item.getId()) diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 73e43428..4d5cd0ab 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -426,7 +426,6 @@ void search_member_failBy_default_skin_size() throws Exception { // expected mockMvc.perform(get("/members/{memberId}", 123L)) .andExpect(status().is4xxClientError()); - } @DisplayName("회원 정보 요청 성공") diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index ee2080b9..1fba2dca 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -117,7 +117,7 @@ void success() throws Exception { payment.request(ORDER_ID); ConfirmPaymentRequest request = confirmPaymentRequest(); given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); - given(memberService.getById(memberId)).willReturn(member()); + given(memberService.findMember(memberId)).willReturn(member()); // expected mockMvc.perform(post("/payments/confirm") 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..fd724c07 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java @@ -0,0 +1,162 @@ +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 + ReportRequest reportRequest = ReportFixture.reportRequest(123L, null, null); + String request = objectMapper.writeValueAsString(reportRequest); + + // expected + mockMvc.perform(post("/reports") + .contentType(MediaType.APPLICATION_JSON) + .content(request)) + .andExpect(status().isBadRequest()); + } + + @DisplayName("회원 조회 실패로 신고 실패") + @WithMember + @Test + void reports_failBy_member() throws Exception { + // given + Member newMember = MemberFixture.member("9999", "n"); + 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/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/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java index ae2b827a..a381738f 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -1,7 +1,7 @@ package com.moabam.support.common; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.BDDMockito.willReturn; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; import java.util.Optional; @@ -9,13 +9,15 @@ 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; -@ExtendWith({FilterProcessExtension.class}) +@Import(DataCleanResolver.class) +@ExtendWith({FilterProcessExtension.class, ClearDataExtension.class}) public class WithoutFilterSupporter { @MockBean 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"); + } +} From 2c7287db9306189cfbe3b6c29d2bb8241f347fe7 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 27 Nov 2023 16:32:44 +0900 Subject: [PATCH 091/185] =?UTF-8?q?hotfix:=20config=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 2e460460..ea25d857 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 2e460460a0048796a3ef6fef17697935027a8708 +Subproject commit ea25d85744c2e6fcedbdb66b34c08837d382814d From 076e0226511c5bb14a020fa6f91251b3730f4370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:37:03 +0900 Subject: [PATCH 092/185] =?UTF-8?q?feat:=20=EC=BF=A0=ED=8F=B0=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Merge branch 'develop' into feature/#75-use-coupon * feat: 쿠폰 지갑에서 특정 회원의 특정 쿠폰 조회 기능 구현 및 테스트 * feat: 쿠폰 지갑에 있는 쿠폰 사용하는 서비스 기능 구현 및 테스트 * feat: 쿠폰 사용 API 기능 구현 및 테스트 * fix: 테스트 코드 에러 수정 * test: RestDoc 업데이트 * refactor : 결제 쿠폰 사용 통합 * Submodule update * test: 테스트 커버리지 추가 --- src/docs/asciidoc/coupon.adoc | 13 ++- .../api/application/bug/BugService.java | 6 +- .../api/application/coupon/CouponService.java | 41 ++++++-- .../application/payment/PaymentService.java | 2 +- .../moabam/api/domain/coupon/CouponType.java | 30 +++++- .../moabam/api/domain/payment/Payment.java | 10 +- .../api/presentation/CouponController.java | 12 ++- .../global/error/model/ErrorMessage.java | 2 + src/main/resources/static/docs/coupon.html | 64 ++++++++---- .../api/application/bug/BugServiceTest.java | 4 +- .../application/coupon/CouponServiceTest.java | 98 ++++++++++++++++++- .../payment/PaymentServiceTest.java | 2 +- .../moabam/api/domain/coupon/CouponTest.java | 4 +- .../api/domain/coupon/CouponTypeTest.java | 25 ++++- .../CouponWalletSearchRepositoryTest.java | 13 ++- .../api/domain/payment/PaymentTest.java | 10 +- .../presentation/CouponControllerTest.java | 62 +++++++++++- .../support/fixture/AuthMemberFixture.java | 11 +++ .../moabam/support/fixture/BugFixture.java | 8 ++ .../moabam/support/fixture/CouponFixture.java | 26 +++-- .../moabam/support/fixture/MemberFixture.java | 16 +++ 21 files changed, 390 insertions(+), 69 deletions(-) create mode 100644 src/test/java/com/moabam/support/fixture/AuthMemberFixture.java diff --git a/src/docs/asciidoc/coupon.adoc b/src/docs/asciidoc/coupon.adoc index 4f424964..222be81b 100644 --- a/src/docs/asciidoc/coupon.adoc +++ b/src/docs/asciidoc/coupon.adoc @@ -96,6 +96,17 @@ 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/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index d35e7549..297df42b 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -16,7 +16,7 @@ import com.moabam.api.domain.bug.Bug; import com.moabam.api.domain.bug.repository.BugHistoryRepository; import com.moabam.api.domain.bug.repository.BugHistorySearchRepository; -import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; import com.moabam.api.domain.payment.Payment; import com.moabam.api.domain.payment.repository.PaymentRepository; import com.moabam.api.domain.product.Product; @@ -67,8 +67,8 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, Payment payment = PaymentMapper.toPayment(memberId, product); if (!isNull(request.couponWalletId())) { - Coupon coupon = couponService.getByWalletIdAndMemberId(request.couponWalletId(), memberId); - payment.applyCoupon(coupon, request.couponWalletId()); + CouponWallet couponWallet = couponService.getWalletByIdAndMemberId(request.couponWalletId(), memberId); + payment.applyCoupon(couponWallet); } paymentRepository.save(payment); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 598e0c27..1d36be55 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -6,11 +6,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.moabam.api.application.member.MemberService; +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.Member; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponStatusRequest; @@ -31,9 +35,11 @@ public class CouponService { private final ClockHolder clockHolder; + private final MemberService memberService; private final CouponManageService couponManageService; private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; + private final CouponWalletRepository couponWalletRepository; private final CouponWalletSearchRepository couponWalletSearchRepository; @Transactional @@ -47,6 +53,30 @@ public void create(AuthMember admin, CreateCouponRequest request) { couponRepository.save(coupon); } + @Transactional + public void use(Long memberId, Long couponWalletId) { + CouponWallet couponWallet = getWalletByIdAndMemberId(couponWalletId, memberId); + Coupon coupon = couponWallet.getCoupon(); + BugType bugType = coupon.getType().getBugType(); + + Member member = memberService.findMember(memberId); + member.getBug().increase(bugType, coupon.getPoint()); + + couponWalletRepository.delete(couponWallet); + } + + @Transactional + public void discount(Long memberId, Long couponWalletId) { + CouponWallet couponWallet = getWalletByIdAndMemberId(couponWalletId, memberId); + Coupon coupon = couponWallet.getCoupon(); + + if (!coupon.getType().isDiscount()) { + throw new BadRequestException(ErrorMessage.INVALID_BUG_COUPON); + } + + couponWalletRepository.delete(couponWallet); + } + @Transactional public void delete(AuthMember admin, Long couponId) { validateAdminRole(admin); @@ -56,12 +86,6 @@ public void delete(AuthMember admin, Long couponId) { couponManageService.deleteCouponManage(coupon.getName()); } - @Transactional - public void use(Long memberId, Long couponWalletId) { - Coupon coupon = getByWalletIdAndMemberId(couponWalletId, memberId); - couponRepository.delete(coupon); - } - public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); @@ -83,10 +107,9 @@ public List getWallet(Long couponId, AuthMember authMember) { return CouponMapper.toMyResponses(couponWallets); } - public Coupon getByWalletIdAndMemberId(Long couponWalletId, Long memberId) { + public CouponWallet getWalletByIdAndMemberId(Long couponWalletId, Long memberId) { return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)) - .getCoupon(); + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); } private void validatePeriod(LocalDate startAt, LocalDate openAt) { diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java index 0055813b..b17c3e8d 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -50,7 +50,7 @@ public void confirm(Long memberId, ConfirmPaymentRequest request) { payment.confirm(response.paymentKey(), response.approvedAt()); if (payment.isCouponApplied()) { - couponService.use(memberId, payment.getCouponWalletId()); + couponService.discount(memberId, payment.getCouponWalletId()); } bugService.charge(memberId, payment.getProduct()); } catch (MoabamException exception) { diff --git a/src/main/java/com/moabam/api/domain/coupon/CouponType.java b/src/main/java/com/moabam/api/domain/coupon/CouponType.java index 722d4e8a..ddcfe57a 100644 --- a/src/main/java/com/moabam/api/domain/coupon/CouponType.java +++ b/src/main/java/com/moabam/api/domain/coupon/CouponType.java @@ -7,6 +7,8 @@ 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; @@ -18,10 +20,10 @@ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public enum CouponType { - MORNING_COUPON("아침"), - NIGHT_COUPON("저녁"), - GOLDEN_COUPON("황금"), - DISCOUNT_COUPON("할인"); + MORNING("아침"), + NIGHT("저녁"), + GOLDEN("황금"), + DISCOUNT("할인"); private final String name; private static final Map COUPON_TYPE_MAP; @@ -35,4 +37,24 @@ 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/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java index 9c1f4dea..f2056499 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -8,7 +8,7 @@ import org.springframework.data.annotation.CreatedDate; import org.springframework.data.jpa.domain.support.AuditingEntityListener; -import com.moabam.api.domain.coupon.Coupon; +import com.moabam.api.domain.coupon.CouponWallet; import com.moabam.api.domain.product.Product; import com.moabam.global.error.exception.BadRequestException; @@ -122,10 +122,10 @@ public boolean isCouponApplied() { return !isNull(this.couponWalletId); } - public void applyCoupon(Coupon coupon, Long couponWalletId) { - this.couponWalletId = couponWalletId; - this.discountAmount = coupon.getPoint(); - this.totalAmount = Math.max(MIN_AMOUNT, this.totalAmount - coupon.getPoint()); + 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) { diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index e61b5c6b..8de012fa 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -33,13 +33,13 @@ public class CouponController { @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) - public void createCoupon(@Auth AuthMember admin, @Valid @RequestBody CreateCouponRequest request) { + public void create(@Auth AuthMember admin, @Valid @RequestBody CreateCouponRequest request) { couponService.create(admin, request); } @DeleteMapping("/admins/coupons/{couponId}") @ResponseStatus(HttpStatus.OK) - public void deleteCoupon(@Auth AuthMember admin, @PathVariable("couponId") Long couponId) { + public void delete(@Auth AuthMember admin, @PathVariable("couponId") Long couponId) { couponService.delete(admin, couponId); } @@ -56,13 +56,21 @@ public List getAllByStatus(@Valid @RequestBody CouponStatusReque } @PostMapping("/coupons") + @ResponseStatus(HttpStatus.OK) public void registerQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { couponManageService.register(authMember, couponName); } @GetMapping({"/my-coupons", "/my-coupons/{couponId}"}) + @ResponseStatus(HttpStatus.OK) public List getWallet(@Auth AuthMember authMember, @PathVariable(value = "couponId", required = false) Long couponId) { return couponService.getWallet(couponId, authMember); } + + @PostMapping("/my-coupons/{couponWalletId}") + @ResponseStatus(HttpStatus.OK) + public void use(@Auth AuthMember authMember, @PathVariable("couponWalletId") Long couponWalletId) { + couponService.use(authMember.id(), couponWalletId); + } } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 078d7fba..4d06627d 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -73,6 +73,8 @@ public enum ErrorMessage { 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("쿠폰 발급 가능 날짜가 중복되었습니다."), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 4b9cc040..a94f14c3 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/33 HTTP/1.1
+
DELETE /admins/coupons/35 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/22 HTTP/1.1
+
GET /coupons/23 HTTP/1.1
 Host: localhost:8080
@@ -540,16 +540,16 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 205 +Content-Length: 198 { - "id" : 22, + "id" : 23, "adminName" : "1admin", "name" : "couponName", "description" : "", "point" : 10, "stock" : 100, - "type" : "MORNING_COUPON", + "type" : "MORNING", "startAt" : "2023-02-01", "openAt" : "2023-01-01" }
@@ -590,16 +590,16 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 206 +Content-Length: 199 [ { - "id" : 23, + "id" : 24, "adminName" : "1admin", "name" : "coupon1", "description" : "", "point" : 10, "stock" : 100, - "type" : "MORNING_COUPON", + "type" : "MORNING", "startAt" : "2023-03-01", "openAt" : "2023-01-01" } ]
@@ -672,38 +672,38 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 507 +Content-Length: 472 [ { - "id" : 17, + "id" : 18, "name" : "c1", "description" : "", "point" : 10, - "type" : "MORNING_COUPON" + "type" : "MORNING" }, { - "id" : 18, + "id" : 19, "name" : "c2", "description" : "", "point" : 10, - "type" : "MORNING_COUPON" + "type" : "MORNING" }, { - "id" : 19, + "id" : 20, "name" : "c3", "description" : "", "point" : 10, - "type" : "MORNING_COUPON" + "type" : "MORNING" }, { - "id" : 20, + "id" : 21, "name" : "c4", "description" : "", "point" : 10, - "type" : "MORNING_COUPON" + "type" : "MORNING" }, { - "id" : 21, + "id" : 22, "name" : "c5", "description" : "", "point" : 10, - "type" : "MORNING_COUPON" + "type" : "MORNING" } ] @@ -711,12 +711,34 @@

응답

-

쿠폰 사용 (진행 중)

+

쿠폰을 사용

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

요청

+
+
+
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:
+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
+
+
+
+
@@ -724,7 +746,7 @@

쿠폰 사용 (진행 중)

diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index fa075dc6..e2bb6d8e 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -22,6 +22,7 @@ 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.coupon.CouponWallet; import com.moabam.api.domain.member.Member; import com.moabam.api.domain.payment.Payment; import com.moabam.api.domain.payment.repository.PaymentRepository; @@ -103,7 +104,8 @@ void apply_coupon_success() { PurchaseProductRequest request = new PurchaseProductRequest(couponWalletId); given(productRepository.findById(productId)).willReturn(Optional.of(bugProduct())); given(paymentRepository.save(any(Payment.class))).willReturn(payment); - given(couponService.getByWalletIdAndMemberId(couponWalletId, memberId)).willReturn(discount1000Coupon()); + given(couponService.getWalletByIdAndMemberId(couponWalletId, memberId)).willReturn( + CouponWallet.create(memberId, discount1000Coupon())); // when PurchaseProductResponse response = bugService.purchaseBugProduct(memberId, productId, request); diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index a8e19049..de7e817e 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -17,12 +17,15 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import com.moabam.api.application.member.MemberService; 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.Member; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponStatusRequest; @@ -37,7 +40,9 @@ import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.BugFixture; import com.moabam.support.fixture.CouponFixture; +import com.moabam.support.fixture.MemberFixture; @ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class CouponServiceTest { @@ -48,9 +53,15 @@ class CouponServiceTest { @Mock CouponManageService couponManageService; + @Mock + MemberService memberService; + @Mock CouponRepository couponRepository; + @Mock + CouponWalletRepository couponWalletRepository; + @Mock CouponSearchRepository couponSearchRepository; @@ -166,7 +177,7 @@ void create_StartAt_BadRequestException() { void create_OpenAt_BadRequestException() { // Given AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - String couponType = CouponType.GOLDEN_COUPON.getName(); + String couponType = CouponType.GOLDEN.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); given(couponRepository.existsByName(any(String.class))).willReturn(false); @@ -300,9 +311,90 @@ void getByWalletIdAndMemberId_success() { .willReturn(Optional.of(couponWallet)); // When - Coupon actual = couponService.getByWalletIdAndMemberId(1L, 1L); + CouponWallet actual = couponService.getWalletByIdAndMemberId(1L, 1L); + + // Then + assertThat(actual.getCoupon().getName()).isEqualTo(couponWallet.getCoupon().getName()); + } + + @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있는 특정 쿠폰을 성공적으로 사용한다. - Void") + @Test + void use_success() { + // Given + Member member = MemberFixture.member(BugFixture.zeroBug()); + Coupon coupon = CouponFixture.coupon(CouponType.GOLDEN, 1000); + CouponWallet couponWallet = CouponWallet.create(1L, coupon); + + given(memberService.findMember(any(Long.class))).willReturn(member); + 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)); + } + + @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 - assertThat(actual.getName()).isEqualTo(couponWallet.getCoupon().getName()); + 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/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index c56e95bf..0f992e1d 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -102,7 +102,7 @@ void use_coupon_success() { paymentService.confirm(memberId, request); // then - verify(couponService, times(1)).use(memberId, couponWalletId); + verify(couponService, times(1)).discount(memberId, couponWalletId); verify(bugService, times(1)).charge(memberId, payment.getProduct()); } diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java index e1e146e3..47dcb2d4 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -24,7 +24,7 @@ void coupon_success() { Coupon actual = Coupon.builder() .name("couponName") .point(10) - .type(CouponType.MORNING_COUPON) + .type(CouponType.MORNING) .stock(100) .startAt(startAt) .openAt(openAt) @@ -36,7 +36,7 @@ void coupon_success() { assertThat(actual.getDescription()).isBlank(); assertThat(actual.getPoint()).isEqualTo(10); assertThat(actual.getStock()).isEqualTo(100); - assertThat(actual.getType()).isEqualTo(CouponType.MORNING_COUPON); + assertThat(actual.getType()).isEqualTo(CouponType.MORNING); assertThat(actual.getStartAt()).isEqualTo(startAt); assertThat(actual.getOpenAt()).isEqualTo(openAt); assertThat(actual.getAdminId()).isEqualTo(1L); diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java index 960df900..cf774ca9 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTypeTest.java @@ -5,6 +5,7 @@ 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; @@ -14,10 +15,10 @@ class CouponTypeTest { @Test void from_success() { // When - CouponType actual = CouponType.from(CouponType.GOLDEN_COUPON.getName()); + CouponType actual = CouponType.from(CouponType.GOLDEN.getName()); // Then - assertThat(actual).isEqualTo(CouponType.GOLDEN_COUPON); + assertThat(actual).isEqualTo(CouponType.GOLDEN); } @DisplayName("존재하지 않는 쿠폰을 가져온다. - NotFoundException") @@ -28,4 +29,24 @@ void from_NotFoundException() { .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/repository/CouponWalletSearchRepositoryTest.java b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java index 1d8d6526..82d1fd5c 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -4,6 +4,7 @@ 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; @@ -97,7 +98,7 @@ void findAllByCouponIdAndMemberId_Id3_success(List couponWallets) assertThat(actual).hasSize(3); } - @DisplayName("회원의 특정 쿠폰 지갑을 성공적으로 조회한다.") + @DisplayName("회원의 특정 쿠폰 지갑을 성공적으로 조회한다. - CouponWallet") @Test void findByIdAndMemberId_success() { // given @@ -111,4 +112,14 @@ void findByIdAndMemberId_success() { // 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/payment/PaymentTest.java b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java index b9ca3d9c..ede0ed7a 100644 --- a/src/test/java/com/moabam/api/domain/payment/PaymentTest.java +++ b/src/test/java/com/moabam/api/domain/payment/PaymentTest.java @@ -10,6 +10,7 @@ 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 { @@ -38,15 +39,14 @@ void success() { // given Payment payment = payment(bugProduct()); Coupon coupon = discount1000Coupon(); - Long couponWalletId = 1L; + CouponWallet couponWallet = CouponWallet.create(1L, coupon); // when - payment.applyCoupon(coupon, couponWalletId); + payment.applyCoupon(couponWallet); // then assertThat(payment.getTotalAmount()).isEqualTo(BUG_PRODUCT_PRICE - 1000); assertThat(payment.getDiscountAmount()).isEqualTo(coupon.getPoint()); - assertThat(payment.getCouponWalletId()).isEqualTo(couponWalletId); } @DisplayName("할인 금액이 더 크면 0으로 처리한다.") @@ -55,10 +55,10 @@ void discount_amount_greater() { // given Payment payment = payment(bugProduct()); Coupon coupon = discount10000Coupon(); - Long couponWalletId = 1L; + CouponWallet couponWallet = CouponWallet.create(1L, coupon); // when - payment.applyCoupon(coupon, couponWalletId); + payment.applyCoupon(couponWallet); // then assertThat(payment.getTotalAmount()).isZero(); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 880962fb..20965ff5 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -32,6 +32,7 @@ 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.global.common.util.ClockHolder; @@ -39,6 +40,7 @@ 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; @@ -55,6 +57,9 @@ class CouponControllerTest extends WithoutFilterSupporter { @Autowired ObjectMapper objectMapper; + @Autowired + MemberRepository memberRepository; + @Autowired CouponRepository couponRepository; @@ -114,7 +119,7 @@ void create_Coupon_StartAt_BadRequestException() throws Exception { @Test void create_Coupon_OpenAt_BadRequestException() throws Exception { // Given - String couponType = CouponType.GOLDEN_COUPON.getName(); + String couponType = CouponType.GOLDEN.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); @@ -442,4 +447,59 @@ void getWallet_no_coupon() throws Exception { .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())); + } } 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/BugFixture.java b/src/test/java/com/moabam/support/fixture/BugFixture.java index a6578d40..ec3b82c9 100644 --- a/src/test/java/com/moabam/support/fixture/BugFixture.java +++ b/src/test/java/com/moabam/support/fixture/BugFixture.java @@ -23,6 +23,14 @@ public static Bug 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) diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 1c67661c..871492da 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -24,7 +24,7 @@ public static Coupon coupon() { return Coupon.builder() .name("couponName") .point(1000) - .type(CouponType.MORNING_COUPON) + .type(CouponType.MORNING) .stock(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) @@ -36,7 +36,7 @@ public static Coupon coupon(String name, int startAt) { return Coupon.builder() .name(name) .point(10) - .type(CouponType.MORNING_COUPON) + .type(CouponType.MORNING) .stock(100) .startAt(LocalDate.of(2023, startAt, 1)) .openAt(LocalDate.of(2023, 1, 1)) @@ -48,7 +48,7 @@ public static Coupon coupon(int point, int stock) { return Coupon.builder() .name("couponName") .point(point) - .type(CouponType.MORNING_COUPON) + .type(CouponType.MORNING) .stock(stock) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) @@ -56,11 +56,23 @@ public static Coupon coupon(int point, int stock) { .build(); } + public static Coupon coupon(CouponType couponType, int point) { + return Coupon.builder() + .name("couponName") + .point(point) + .type(couponType) + .stock(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_COUPON) + .type(CouponType.MORNING) .stock(100) .startAt(LocalDate.of(2023, startMonth, 1)) .openAt(LocalDate.of(2023, openMonth, 1)) @@ -72,7 +84,7 @@ public static Coupon discount1000Coupon() { return Coupon.builder() .name(DISCOUNT_1000_COUPON_NAME) .point(1000) - .type(CouponType.DISCOUNT_COUPON) + .type(CouponType.DISCOUNT) .stock(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) @@ -84,7 +96,7 @@ public static Coupon discount10000Coupon() { return Coupon.builder() .name(DISCOUNT_10000_COUPON_NAME) .point(10000) - .type(CouponType.DISCOUNT_COUPON) + .type(CouponType.DISCOUNT) .stock(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 2, 1)) @@ -97,7 +109,7 @@ public static CreateCouponRequest createCouponRequest() { .name("couponName") .description("coupon description") .point(10) - .type(CouponType.GOLDEN_COUPON.getName()) + .type(CouponType.GOLDEN.getName()) .stock(10) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java index 50ff801c..3bf73d15 100644 --- a/src/test/java/com/moabam/support/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -1,5 +1,6 @@ package com.moabam.support.fixture; +import com.moabam.api.domain.bug.Bug; import com.moabam.api.domain.member.Member; public final class MemberFixture { @@ -14,6 +15,21 @@ public static Member member() { .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, String nickname) { return Member.builder() .socialId(socialId) From c1e925e7f285fef915a754a75659163ed11d258f Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Mon, 27 Nov 2023 17:37:52 +0900 Subject: [PATCH 093/185] =?UTF-8?q?feat:=20=EB=AF=B8=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=EC=9D=98=20=EB=B0=A9=20=EC=A0=95=EB=B3=B4=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20(#161)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ClockHolder LocalDate 추가 * refactor: RoomService 리팩토링 * refactor: SearchService 리팩토링 * refactor: 방 입장, 퇴장 리팩토링 * refactor: CertifiactionService 리팩토링 * refactor: RoomController 리팩토링 * test: InventorySearchRepository 테스트 추가 * chore: 테스트 코드 In-memory H2에서 MySQL로 변경 * feat: CertifyRoom Transaction 분리, 비관적 락 적용 * feat: 방 입장 낙관적 락 적용 * refactor: MySQL 변경으로 일부 테스트 수정 * test: 방 인증, 입장 동시성 테스트 작성 * test: 방장 위임 테스트 작성 * fix: 방 입장 낙관적 락 -> 비관적 락으로 변경 * feat: 방 참여 여부 확인, 참여 중이지 않은 방 정보 부르기 컨트롤러 * feat: 방 참여 여부 확인 서비스 추가 * feat: 참여중이지 않은 방 정보 조회 서비스 * test: 통합 테스트 코드 작성 * test: 테스트 코드 보완 * fix: memberId 가져오기로 변경 * refactor: redirection -> boolean으로 변경 * fix: Search 쿼리 수정 --------- Co-authored-by: Dev Uni --- .../api/application/room/RoomService.java | 9 +++ .../api/application/room/SearchService.java | 50 ++++++++++++-- .../application/room/mapper/RoomMapper.java | 37 +++++++++++ .../room/repository/RoomRepository.java | 4 +- .../api/dto/room/RoomDetailsResponse.java | 1 + .../UnJoinedRoomCertificateRankResponse.java | 14 ++++ .../dto/room/UnJoinedRoomDetailsResponse.java | 26 ++++++++ .../api/presentation/RoomController.java | 13 ++++ .../api/presentation/RoomControllerTest.java | 66 +++++++++++++++++++ 9 files changed, 211 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/room/UnJoinedRoomCertificateRankResponse.java create mode 100644 src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index d50c4658..fbd1fc4e 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -147,6 +147,15 @@ public void deportParticipant(Long managerId, Long roomId, Long memberId) { member.exitRoom(room.getRoomType()); } + public boolean checkIfParticipant(Long memberId, Long roomId) { + try { + getParticipant(memberId, roomId); + return true; + } catch (NotFoundException e) { + return false; + } + } + public void validateRoomById(Long roomId) { if (!roomRepository.existsById(roomId)) { throw new NotFoundException(ROOM_NOT_FOUND); diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index 73ee5cb9..9e62b9d0 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -1,12 +1,8 @@ package com.moabam.api.application.room; -import static com.moabam.global.common.util.GlobalConstant.NOT_COMPLETED_RANK; -import static com.moabam.global.common.util.GlobalConstant.ROOM_FIXED_SEARCH_SIZE; -import static com.moabam.global.error.model.ErrorMessage.INVENTORY_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.PARTICIPANT_NOT_FOUND; -import static com.moabam.global.error.model.ErrorMessage.ROOM_DETAILS_ERROR; -import static com.moabam.global.error.model.ErrorMessage.ROOM_MODIFY_UNAUTHORIZED_REQUEST; -import static org.apache.commons.lang3.StringUtils.isEmpty; +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.Period; @@ -51,6 +47,8 @@ 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; @@ -187,6 +185,44 @@ public GetAllRoomsResponse searchRooms(String keyword, @Nullable RoomType roomTy 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; 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 index 7e7a9d0c..21e86124 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -1,8 +1,12 @@ 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.dto.room.CreateRoomRequest; @@ -17,6 +21,8 @@ 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; @@ -44,6 +50,7 @@ public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room .managerNickName(managerNickname) .roomImage(room.getRoomImage()) .level(room.getLevel()) + .exp(room.getExp()) .roomType(room.getRoomType()) .certifyTime(room.getCertifyTime()) .currentUserCount(room.getCurrentUserCount()) @@ -131,4 +138,34 @@ public static GetAllRoomsResponse toSearchAllRoomsResponse(boolean 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()) + .exp(room.getExp()) + .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().getImage()) + .sleepImage(inventory.getItem().getImage()) + .build(); + } } 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 index 494942fc..b086872f 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java @@ -42,9 +42,9 @@ List searchByKeywordAndRoomType(@Param(value = "keyword") String keyword, 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% " + + "where (rm.title like %:keyword% " + "or rm.manager_nickname like %:keyword% " - + "or rt.content like %:keyword% " + + "or rt.content like %:keyword%) " + "and rm.room_type = :roomType " + "and rm.id < :roomId " + "order by rm.id desc limit 11", nativeQuery = true) diff --git a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java index abb7d06b..339ecbcc 100644 --- a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -15,6 +15,7 @@ public record RoomDetailsResponse( String managerNickName, String roomImage, int level, + int exp, RoomType roomType, int certifyTime, int currentUserCount, 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..d521ea30 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java @@ -0,0 +1,26 @@ +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 exp, + RoomType roomType, + int certifyTime, + int currentUserCount, + int maxUserCount, + String announcement, + List routines, + List certifiedRanks +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 55146a8e..ce4725e3 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -32,6 +32,7 @@ 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; @@ -87,6 +88,18 @@ public void exitRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long r 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, diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 580005b7..265450c4 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -971,6 +971,72 @@ void get_join_history_success() throws Exception { .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", "testtest"); + member1 = memberRepository.save(member1); + + Item item = ItemFixture.nightMageSkin(); + + Inventory inventory = InventoryFixture.inventory(member1.getId(), item); + inventory.select(); + + 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 From a8a32bc4871fa77ee95975bb3e9ba706c36d299f Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Mon, 27 Nov 2023 18:29:31 +0900 Subject: [PATCH 094/185] =?UTF-8?q?fix:=20noskin=20image=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#162)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 회원 로그인 시 기본 부엉이, 오목눈이 생성 기능 추가 및 테스트 코드 변경 * fix: 테스트 코드 변경 * refacotr: config 수정 --- build.gradle | 8 +++ .../api/application/member/MemberMapper.java | 31 ++++++---- .../api/application/member/MemberService.java | 40 +++++++------ .../com/moabam/api/domain/member/Member.java | 31 +++++++++- .../repository/MemberSearchRepository.java | 2 + .../com/moabam/api/dto/member/MemberInfo.java | 9 ++- .../dto/member/MemberInfoSearchResponse.java | 2 + .../global/common/util/BaseDataCode.java | 11 ++++ .../global/common/util/BaseImageUrl.java | 6 +- .../global/error/model/ErrorMessage.java | 2 + .../application/member/MemberServiceTest.java | 56 ++++--------------- .../moabam/api/domain/entity/MemberTest.java | 4 +- .../MemberAuthorizeControllerTest.java | 28 ++++++---- .../presentation/MemberControllerTest.java | 17 ++++-- .../moabam/support/fixture/ItemFixture.java | 8 ++- .../fixture/MemberInfoSearchFixture.java | 20 ++++--- 16 files changed, 169 insertions(+), 106 deletions(-) create mode 100644 src/main/java/com/moabam/global/common/util/BaseDataCode.java diff --git a/build.gradle b/build.gradle index 9783e62f..2f40eeda 100644 --- a/build.gradle +++ b/build.gradle @@ -19,6 +19,14 @@ ext { snippetsDir = file('build/generated-snippets') } +def querydslSrcDir = 'src/main/generated' +clean { + delete file(querydslSrcDir) +} +tasks.withType(JavaCompile) { + options.generatedSourceOutputDirectory = file(querydslSrcDir) +} + configurations { asciidoctorExtensions diff --git a/src/main/java/com/moabam/api/application/member/MemberMapper.java b/src/main/java/com/moabam/api/application/member/MemberMapper.java index 3c823e22..148a0b8d 100644 --- a/src/main/java/com/moabam/api/application/member/MemberMapper.java +++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java @@ -2,15 +2,17 @@ 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 java.util.stream.Collectors; 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.BadgeType; import com.moabam.api.domain.member.Member; import com.moabam.api.dto.member.BadgeResponse; @@ -41,6 +43,8 @@ public static MemberInfoSearchResponse toMemberInfoSearchResponse(List(badgeTypes)) @@ -50,8 +54,7 @@ public static MemberInfoSearchResponse toMemberInfoSearchResponse(List inventories) { + public static MemberInfoResponse toMemberInfoResponse(MemberInfoSearchResponse memberInfoSearchResponse) { long certifyCount = memberInfoSearchResponse.totalCertifyCount(); return MemberInfoResponse.builder() @@ -60,7 +63,7 @@ public static MemberInfoResponse toMemberInfoResponse(MemberInfoSearchResponse m .intro(memberInfoSearchResponse.intro()) .level(certifyCount / LEVEL_DIVISOR) .exp(certifyCount % LEVEL_DIVISOR) - .birds(defaultSkins(inventories)) + .birds(defaultSkins(memberInfoSearchResponse.morningImage(), memberInfoSearchResponse.nightImage())) .badges(badgedNames(memberInfoSearchResponse.badges())) .goldenBug(memberInfoSearchResponse.goldenBug()) .morningBug(memberInfoSearchResponse.morningBug()) @@ -68,15 +71,23 @@ public static MemberInfoResponse toMemberInfoResponse(MemberInfoSearchResponse m .build(); } + public static Inventory toInventory(Long memberId, Item item) { + return Inventory.builder() + .memberId(memberId) + .item(item) + .isDefault(true) + .build(); + } + private static List badgedNames(Set badgeTypes) { return BadgeType.memberBadgeMap(badgeTypes); } - private static Map defaultSkins(List inventories) { - return inventories.stream() - .collect(Collectors.toMap( - inventory -> inventory.getItem().getType().name(), - inventory -> inventory.getItem().getImage() - )); + 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 index cbc5f451..b6505ce3 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -11,7 +11,9 @@ import com.moabam.api.application.auth.mapper.AuthMapper; import com.moabam.api.domain.item.Inventory; -import com.moabam.api.domain.item.repository.InventorySearchRepository; +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; @@ -22,8 +24,8 @@ import com.moabam.api.dto.member.MemberInfoSearchResponse; import com.moabam.api.dto.member.ModifyMemberRequest; 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.common.util.GlobalConstant; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -37,7 +39,8 @@ public class MemberService { private final MemberRepository memberRepository; - private final InventorySearchRepository inventorySearchRepository; + private final InventoryRepository inventoryRepository; + private final ItemRepository itemRepository; private final MemberSearchRepository memberSearchRepository; private final ClockHolder clockHolder; @@ -78,11 +81,8 @@ public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { if (!isMe) { searchId = memberId; } - MemberInfoSearchResponse memberInfoSearchResponse = findMemberInfo(searchId, isMe); - List inventories = getDefaultSkin(searchId); - - return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse, inventories); + return MemberMapper.toMemberInfoResponse(memberInfoSearchResponse); } @Transactional @@ -105,19 +105,27 @@ private void validateNickname(String nickname) { } } - private List getDefaultSkin(Long searchId) { - List inventories = inventorySearchRepository.findDefaultSkin(searchId); - if (inventories.size() != GlobalConstant.DEFAULT_SKIN_SIZE) { - throw new BadRequestException(INVALID_DEFAULT_SKIN_SIZE); - } + private Member signUp(Long socialId) { + Member member = MemberMapper.toMember(socialId); + return memberRepository.save(member); + } - return inventories; + private void saveMyEgg(Member member) { + List items = getBasicEggs(); + List inventories = items.stream() + .map(item -> MemberMapper.toInventory(member.getId(), item)) + .toList(); + inventoryRepository.saveAll(inventories); } - private Member signUp(Long socialId) { - Member member = MemberMapper.toMember(socialId); + private List getBasicEggs() { + List items = itemRepository.findAllById(List.of(BaseDataCode.MORNING_EGG, BaseDataCode.NIGHT_EGG)); - return memberRepository.save(member); + if (items.isEmpty()) { + throw new BadRequestException(BASIC_SKIN_NOT_FOUND); + } + + return items; } private MemberInfoSearchResponse findMemberInfo(Long searchId, boolean isMe) { diff --git a/src/main/java/com/moabam/api/domain/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index 33f3aab4..3cfc9f24 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -1,7 +1,9 @@ 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; @@ -10,9 +12,11 @@ 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.common.util.BaseImageUrl; +import com.moabam.global.error.exception.NotFoundException; import jakarta.persistence.Column; import jakarta.persistence.Embedded; @@ -52,6 +56,12 @@ public class Member extends BaseTimeEntity { @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; @@ -85,7 +95,9 @@ private Member(Long id, String socialId, Bug bug) { this.socialId = requireNonNull(socialId); this.nickname = createNickName(); this.intro = ""; - this.profileImage = BaseImageUrl.MEMBER_PROFILE_URL; + 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; } @@ -134,6 +146,21 @@ public void changeIntro(String intro) { public void changeProfileUri(String newProfileUri) { this.profileImage = requireNonNullElse(newProfileUri, profileImage); + this.profileImage = requireNonNullElse(newProfileUri, profileImage); + } + + public void changeDefaultSkintUrl(Item item) throws NotFoundException { + if (ItemType.MORNING.equals(item.getType())) { + this.morningImage = item.getImage(); + return; + } + + if (ItemType.NIGHT.equals(item.getType())) { + this.nightImage = item.getImage(); + return; + } + + throw new NotFoundException(SKIN_TYPE_NOT_FOUND); } private String createNickName() { 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 index a90e0009..b853b871 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -54,6 +54,8 @@ 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)); diff --git a/src/main/java/com/moabam/api/dto/member/MemberInfo.java b/src/main/java/com/moabam/api/dto/member/MemberInfo.java index 189469c4..56ed7c77 100644 --- a/src/main/java/com/moabam/api/dto/member/MemberInfo.java +++ b/src/main/java/com/moabam/api/dto/member/MemberInfo.java @@ -5,6 +5,8 @@ public record MemberInfo( String nickname, String profileImage, + String morningImage, + String nightImage, String intro, long totalCertifyCount, BadgeType badges, @@ -13,8 +15,9 @@ public record MemberInfo( Integer nightBug ) { - public MemberInfo(String nickname, String profileImage, String intro, - long totalCertifyCount, BadgeType badges) { - this(nickname, profileImage, intro, totalCertifyCount, badges, null, null, null); + 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/MemberInfoSearchResponse.java b/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java index e22070cc..1391e383 100644 --- a/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java +++ b/src/main/java/com/moabam/api/dto/member/MemberInfoSearchResponse.java @@ -10,6 +10,8 @@ public record MemberInfoSearchResponse( String nickname, String profileImage, + String morningImage, + String nightImage, String intro, long totalCertifyCount, Set badges, 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 index 99544648..ac66f717 100644 --- a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -6,11 +6,15 @@ @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 MEMBER_PROFILE_URL = "/profile/baseUrl"; + public static final String DEFAULT_MORNING_EGG_URL = "moabam/skins/오목눈이/기본/오목눈이알.png"; + public static final String DEFAULT_NIGHT_EGG_URL = "moabam/skins/부엉이/기본/부엉이알.png"; + public static final String MEMBER_PROFILE_URL = "moabam/default/기본회원프로필.png"; } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 4d06627d..408e5574 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -40,7 +40,9 @@ public enum ErrorMessage { UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS("카카오 연결 요청 실패로 Rollback하였습니다."), NICKNAME_CONFLICT("이미 존재하는 닉네임입니다."), + BASIC_SKIN_NOT_FOUND("기본 스킨 오류 발생, 관리자에게 문의하세요"), INVALID_DEFAULT_SKIN_SIZE("기본 스킨은 2개여야 합니다. 관리자에게 문의하세요"), + SKIN_TYPE_NOT_FOUND("스킨 타입이 없습니다. 관리자에게 문의하세요"), BUG_NOT_ENOUGH("보유한 벌레가 부족합니다."), diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index a30978cc..3271f6f1 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -1,6 +1,5 @@ package com.moabam.api.application.member; -import static com.moabam.global.error.model.ErrorMessage.*; import static org.assertj.core.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -19,12 +18,15 @@ 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.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.global.auth.model.AuthMember; @@ -55,6 +57,12 @@ class MemberServiceTest { @Mock InventorySearchRepository inventorySearchRepository; + @Mock + InventoryRepository inventoryRepository; + + @Mock + ItemRepository itemRepository; + @Mock ClockHolder clockHolder; @@ -149,10 +157,6 @@ void search_my_info_success(@WithMember AuthMember authMember) { given(memberSearchRepository.findMemberAndBadges(authMember.id(), true)) .willReturn(MemberInfoSearchFixture.friendMemberInfo(total)); - given(inventorySearchRepository.findDefaultSkin(authMember.id())) - .willReturn(List.of( - InventoryFixture.inventory(authMember.id(), morning), - InventoryFixture.inventory(authMember.id(), night))); // When + Then MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); @@ -176,10 +180,11 @@ void success(@WithMember AuthMember authMember) { Item night = ItemFixture.nightMageSkin(); Inventory morningSkin = InventoryFixture.inventory(searchId, morning); Inventory nightSkin = InventoryFixture.inventory(searchId, night); + List memberInfos = MemberInfoSearchFixture + .myInfo(morningSkin.getItem().getImage(), nightSkin.getItem().getImage()); given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) - .willReturn(MemberInfoSearchFixture.myInfo()); - given(inventorySearchRepository.findDefaultSkin(searchId)).willReturn(List.of(morningSkin, nightSkin)); + .willReturn(memberInfos); // when MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); @@ -188,43 +193,6 @@ void success(@WithMember AuthMember authMember) { assertThat(memberInfoResponse.birds()).containsEntry("MORNING", morningSkin.getItem().getImage()); assertThat(memberInfoResponse.birds()).containsEntry("NIGHT", nightSkin.getItem().getImage()); } - - @DisplayName("기본 스킨이 없어서 예외 발생") - @Test - void failBy_underSize(@WithMember AuthMember authMember) { - // given - given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) - .willReturn(MemberInfoSearchFixture.friendMemberInfo()); - given(inventorySearchRepository.findDefaultSkin(anyLong())).willReturn(List.of()); - - // when - assertThatThrownBy(() -> memberService.searchInfo(authMember, 123L)) - .isInstanceOf(BadRequestException.class) - .hasMessage(INVALID_DEFAULT_SKIN_SIZE.getMessage()); - } - - @DisplayName("기본 스킨이 3개 이상이어서 예외 발생") - @Test - void failBy_overSize(@WithMember AuthMember authMember) { - // given - long searchId = 1L; - Item morning = ItemFixture.morningSantaSkin().build(); - Item night = ItemFixture.nightMageSkin(); - Item kill = ItemFixture.morningKillerSkin().build(); - Inventory morningSkin = InventoryFixture.inventory(searchId, morning); - Inventory nightSkin = InventoryFixture.inventory(searchId, night); - Inventory killSkin = InventoryFixture.inventory(searchId, kill); - - given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) - .willReturn(MemberInfoSearchFixture.myInfo()); - given(inventorySearchRepository.findDefaultSkin(searchId)) - .willReturn(List.of(morningSkin, nightSkin, killSkin)); - - // when - assertThatThrownBy(() -> memberService.searchInfo(authMember, null)) - .isInstanceOf(BadRequestException.class) - .hasMessage(INVALID_DEFAULT_SKIN_SIZE.getMessage()); - } } @DisplayName("사용자 정보 수정 성공") diff --git a/src/test/java/com/moabam/api/domain/entity/MemberTest.java b/src/test/java/com/moabam/api/domain/entity/MemberTest.java index b3168af3..1abd753b 100644 --- a/src/test/java/com/moabam/api/domain/entity/MemberTest.java +++ b/src/test/java/com/moabam/api/domain/entity/MemberTest.java @@ -1,5 +1,6 @@ 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.*; @@ -11,7 +12,6 @@ import com.moabam.api.domain.member.Member; import com.moabam.api.domain.member.Role; import com.moabam.api.domain.room.RoomType; -import com.moabam.global.common.util.BaseImageUrl; import com.moabam.support.fixture.MemberFixture; class MemberTest { @@ -41,7 +41,7 @@ void create_member_noImage_success() { .build(); assertAll( - () -> assertThat(member.getProfileImage()).isEqualTo(BaseImageUrl.MEMBER_PROFILE_URL), + () -> 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(), diff --git a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java index 0f5d04d7..d0df0151 100644 --- a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java @@ -1,16 +1,12 @@ package com.moabam.api.presentation; -import static org.mockito.BDDMockito.any; -import static org.mockito.BDDMockito.doReturn; -import static org.mockito.BDDMockito.willReturn; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; -import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus; -import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.cookie; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import 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; @@ -40,6 +36,8 @@ 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; @@ -48,6 +46,7 @@ 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 @@ -62,6 +61,9 @@ class MemberAuthorizeControllerTest { @Autowired OAuth2AuthorizationServerRequestService oAuth2AuthorizationServerRequestService; + @Autowired + ItemRepository itemRepository; + @SpyBean AuthorizationService authorizationService; @@ -122,6 +124,10 @@ void social_login_signUp_request_success() throws Exception { 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 = diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 4d5cd0ab..fb4b4b9e 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -133,7 +133,7 @@ void allSetUp() { restTemplateBuilder = new RestTemplateBuilder() .errorHandler(new RestTemplateResponseHandler()); - member = MemberFixture.member("1", "nickname"); + member = MemberFixture.member("1234567890987654", "nickname"); member.increaseTotalCertifyCount(); memberRepository.save(member); } @@ -160,7 +160,6 @@ void logout_success() throws Exception { Assertions.assertThatThrownBy(() -> tokenRepository.getTokenSaveValue(member.getId())) .isInstanceOf(UnauthorizedException.class); - } @DisplayName("회원 삭제 성공 테스트") @@ -269,6 +268,9 @@ void search_my_info_success() throws Exception { Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); + member.changeDefaultSkintUrl(night); + member.changeDefaultSkintUrl(morning); + // expected mockMvc.perform(get("/members")) .andExpect(status().isOk()) @@ -279,8 +281,8 @@ void search_my_info_success() throws Exception { 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("$.birds.MORNING").value(morningInven.getItem().getImage()), + // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), @@ -325,8 +327,8 @@ void search_my_info_with_no_badge_success() throws Exception { 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("$.birds.MORNING").value(morningInven.getItem().getImage()), + // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(false), @@ -369,6 +371,9 @@ void search_friend_info_success() throws Exception { morningInven.select(); Inventory killerInven = InventoryFixture.inventory(friend.getId(), killer); + friend.changeDefaultSkintUrl(morning); + friend.changeDefaultSkintUrl(night); + memberRepository.flush(); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); // expected diff --git a/src/test/java/com/moabam/support/fixture/ItemFixture.java b/src/test/java/com/moabam/support/fixture/ItemFixture.java index 087b87d3..c45d535d 100644 --- a/src/test/java/com/moabam/support/fixture/ItemFixture.java +++ b/src/test/java/com/moabam/support/fixture/ItemFixture.java @@ -1,5 +1,7 @@ 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.ItemType; @@ -7,11 +9,11 @@ public class ItemFixture { public static final String MORNING_SANTA_SKIN_NAME = "산타 오목눈이"; - public static final String MORNING_SANTA_SKIN_IMAGE = "/item/morning_santa.png"; + 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 = "/item/morning_killer.png"; + 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 = "/item/night_mage.png"; + public static final String NIGHT_MAGE_SKIN_IMAGE = IMAGE_DOMAIN + "item/night_mage.png"; public static Item.ItemBuilder morningSantaSkin() { return Item.builder() diff --git a/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java index 9c0c4d26..e4bfa647 100644 --- a/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java @@ -1,5 +1,7 @@ package com.moabam.support.fixture; +import static com.moabam.global.common.util.BaseImageUrl.*; + import java.util.List; import com.moabam.api.domain.member.BadgeType; @@ -8,9 +10,11 @@ public class MemberInfoSearchFixture { private static final String NICKNAME = "nickname"; - private static final String PROFILE_IMAGE = "profileuri"; + 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); @@ -18,19 +22,19 @@ public static List friendMemberInfo() { public static List friendMemberInfo(long total) { return List.of( - new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, total, BadgeType.MORNING_BIRTH, + new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.MORNING_BIRTH, 0, 0, 0), - new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, total, BadgeType.NIGHT_BIRTH, + new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.NIGHT_BIRTH, 0, 0, 0) ); } - public static List myInfo() { + public static List myInfo(String morningImage, String nightImage) { return List.of( - new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, TOTAL_CERTIFY_COUNT, BadgeType.MORNING_BIRTH, - 0, 0, 0), - new MemberInfo(NICKNAME, PROFILE_IMAGE, INTRO, TOTAL_CERTIFY_COUNT, BadgeType.NIGHT_BIRTH, - 0, 0, 0) + new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, + BadgeType.MORNING_BIRTH, 0, 0, 0), + new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, + BadgeType.NIGHT_BIRTH, 0, 0, 0) ); } } From d03feb319ed85a66927ba0f816ebd8ea20698bfe Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Tue, 28 Nov 2023 16:27:36 +0900 Subject: [PATCH 095/185] =?UTF-8?q?feat:=20=EB=B2=8C=EB=A0=88=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81/=EC=B6=A9=EC=A0=84/=EC=82=AC=EC=9A=A9=20=EC=8B=9C=20?= =?UTF-8?q?=EB=82=B4=EC=97=AD=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#165)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 벌레 사용 + 벌레 내역 저장 로직 하나의 메서드로 분리 * refactor: 벌레 보상 + 벌레 내역 저장 로직 하나의 메서드로 분리 * test: 아이템 서비스 테스트 수정 * test: BugService Mock 추가 * test: 벌레 사용/충전/보상 서비스 테스트 * refactor: 쿠폰 사용 + 벌레 내역 저장 로직 하나의 메서드로 분리 * fix: 불필요한 Mock 제거 --- .../moabam/api/application/bug/BugMapper.java | 36 +++++++++--- .../api/application/bug/BugService.java | 51 +++++++++++++--- .../api/application/coupon/CouponService.java | 9 +-- .../api/application/item/ItemService.java | 10 +--- .../room/CertificationService.java | 6 +- .../java/com/moabam/api/domain/bug/Bug.java | 31 +++++----- .../api/presentation/ItemController.java | 3 +- .../api/application/bug/BugServiceTest.java | 58 +++++++++++++++++-- .../application/coupon/CouponServiceTest.java | 9 +-- .../api/application/item/ItemServiceTest.java | 12 ++-- .../room/CertificationServiceTest.java | 12 ++-- .../api/presentation/ItemControllerTest.java | 4 ++ 12 files changed, 169 insertions(+), 72 deletions(-) diff --git a/src/main/java/com/moabam/api/application/bug/BugMapper.java b/src/main/java/com/moabam/api/application/bug/BugMapper.java index cf40d215..af91b206 100644 --- a/src/main/java/com/moabam/api/application/bug/BugMapper.java +++ b/src/main/java/com/moabam/api/application/bug/BugMapper.java @@ -20,15 +20,6 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class BugMapper { - 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 BugResponse toBugResponse(Bug bug) { return BugResponse.builder() .morningBug(bug.getMorningBug()) @@ -54,6 +45,15 @@ public static BugHistoryResponse toBugHistoryResponse(List 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/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 1d36be55..3653802c 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -6,7 +6,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.moabam.api.application.member.MemberService; +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; @@ -14,7 +14,6 @@ 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.Member; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponStatusRequest; @@ -35,7 +34,7 @@ public class CouponService { private final ClockHolder clockHolder; - private final MemberService memberService; + private final BugService bugService; private final CouponManageService couponManageService; private final CouponRepository couponRepository; private final CouponSearchRepository couponSearchRepository; @@ -59,9 +58,7 @@ public void use(Long memberId, Long couponWalletId) { Coupon coupon = couponWallet.getCoupon(); BugType bugType = coupon.getType().getBugType(); - Member member = memberService.findMember(memberId); - member.getBug().increase(bugType, coupon.getPoint()); - + bugService.applyCoupon(memberId, bugType, coupon.getPoint()); couponWalletRepository.delete(couponWallet); } diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java index a0d5015f..8e0f9f72 100644 --- a/src/main/java/com/moabam/api/application/item/ItemService.java +++ b/src/main/java/com/moabam/api/application/item/ItemService.java @@ -7,10 +7,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.moabam.api.application.bug.BugMapper; +import com.moabam.api.application.bug.BugService; import com.moabam.api.application.member.MemberService; -import com.moabam.api.domain.bug.Bug; -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; @@ -32,11 +30,11 @@ 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; - private final BugHistoryRepository bugHistoryRepository; public ItemsResponse getItems(Long memberId, ItemType type) { Item defaultItem = getDefaultInventory(memberId, type).getItem(); @@ -54,12 +52,10 @@ public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request validateAlreadyPurchased(memberId, itemId); item.validatePurchasable(request.bugType(), member.getLevel()); - Bug bug = member.getBug(); int price = item.getPrice(request.bugType()); - bug.use(request.bugType(), price); + bugService.use(member, request.bugType(), price); inventoryRepository.save(ItemMapper.toInventory(memberId, item)); - bugHistoryRepository.save(BugMapper.toUseBugHistory(memberId, request.bugType(), price)); } @Transactional diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index f8e72e9d..3a679550 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -12,6 +12,7 @@ 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.application.room.mapper.CertificationsMapper; import com.moabam.api.domain.bug.BugType; @@ -51,6 +52,7 @@ public class CertificationService { private final DailyRoomCertificationRepository dailyRoomCertificationRepository; private final DailyMemberCertificationRepository dailyMemberCertificationRepository; private final MemberService memberService; + private final BugService bugService; private final ClockHolder clockHolder; @Transactional @@ -88,7 +90,7 @@ public void certifyRoom(CertifiedMemberInfo certifyInfo) { return; } - member.getBug().increase(bugType, room.getLevel()); + bugService.reward(member, bugType, room.getLevel()); } public boolean existsMemberCertification(Long memberId, Long roomId, LocalDate date) { @@ -187,6 +189,6 @@ private void provideBugToCompletedMembers(BugType bugType, List completedMember.getBug().increase(bugType, expAppliedRoomLevel)); + .forEach(completedMember -> bugService.reward(completedMember, bugType, expAppliedRoomLevel)); } } diff --git a/src/main/java/com/moabam/api/domain/bug/Bug.java b/src/main/java/com/moabam/api/domain/bug/Bug.java index ae07f4d6..f581c870 100644 --- a/src/main/java/com/moabam/api/domain/bug/Bug.java +++ b/src/main/java/com/moabam/api/domain/bug/Bug.java @@ -45,10 +45,11 @@ private int validateBugCount(int bug) { return bug; } - public void use(BugType bugType, int price) { + public void use(BugType bugType, int count) { int currentBug = getBug(bugType); - validateEnoughBug(currentBug, price); - decrease(bugType, price); + + validateEnoughBug(currentBug, count); + decrease(bugType, count); } private int getBug(BugType bugType) { @@ -59,29 +60,29 @@ private int getBug(BugType bugType) { }; } - private void validateEnoughBug(int currentBug, int price) { - if (price > currentBug) { + private void validateEnoughBug(int currentBug, int count) { + if (currentBug < count) { throw new BadRequestException(BUG_NOT_ENOUGH); } } - private void decrease(BugType bugType, int bug) { + private void decrease(BugType bugType, int count) { switch (bugType) { - case MORNING -> this.morningBug -= bug; - case NIGHT -> this.nightBug -= bug; - case GOLDEN -> this.goldenBug -= bug; + case MORNING -> this.morningBug -= count; + case NIGHT -> this.nightBug -= count; + case GOLDEN -> this.goldenBug -= count; } } - public void increase(BugType bugType, int bug) { + public void increase(BugType bugType, int count) { switch (bugType) { - case MORNING -> this.morningBug += bug; - case NIGHT -> this.nightBug += bug; - case GOLDEN -> this.goldenBug += bug; + case MORNING -> this.morningBug += count; + case NIGHT -> this.nightBug += count; + case GOLDEN -> this.goldenBug += count; } } - public void charge(int quantity) { - this.goldenBug += quantity; + public void charge(int count) { + this.goldenBug += count; } } diff --git a/src/main/java/com/moabam/api/presentation/ItemController.java b/src/main/java/com/moabam/api/presentation/ItemController.java index fbb55663..7b55c155 100644 --- a/src/main/java/com/moabam/api/presentation/ItemController.java +++ b/src/main/java/com/moabam/api/presentation/ItemController.java @@ -35,8 +35,7 @@ public ItemsResponse getItems(@Auth AuthMember member, @RequestParam ItemType ty @PostMapping("/{itemId}/purchase") @ResponseStatus(HttpStatus.OK) - public void purchaseItem(@Auth AuthMember member, - @PathVariable Long itemId, + public void purchaseItem(@Auth AuthMember member, @PathVariable Long itemId, @Valid @RequestBody PurchaseItemRequest request) { itemService.purchaseItem(member.id(), itemId, request); } diff --git a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java index e2bb6d8e..7c147a98 100644 --- a/src/test/java/com/moabam/api/application/bug/BugServiceTest.java +++ b/src/test/java/com/moabam/api/application/bug/BugServiceTest.java @@ -1,6 +1,7 @@ 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.*; @@ -18,11 +19,13 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.application.coupon.CouponService; 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; @@ -46,7 +49,7 @@ class BugServiceTest { MemberService memberService; @Mock - CouponService couponService; + BugHistoryRepository bugHistoryRepository; @Mock ProductRepository productRepository; @@ -54,6 +57,9 @@ class BugServiceTest { @Mock PaymentRepository paymentRepository; + @Mock + CouponWalletSearchRepository couponWalletSearchRepository; + @DisplayName("벌레를 조회한다.") @Test void get_bug_success() { @@ -100,12 +106,13 @@ void apply_coupon_success() { 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(couponService.getWalletByIdAndMemberId(couponWalletId, memberId)).willReturn( - CouponWallet.create(memberId, discount1000Coupon())); + given(couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId)).willReturn( + Optional.of(couponWallet)); // when PurchaseProductResponse response = bugService.purchaseBugProduct(memberId, productId, request); @@ -130,4 +137,47 @@ void product_not_found_exception() { .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/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index de7e817e..86db23f0 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -17,7 +17,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import com.moabam.api.application.member.MemberService; +import com.moabam.api.application.bug.BugService; import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.CouponType; import com.moabam.api.domain.coupon.CouponWallet; @@ -25,7 +25,6 @@ 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.Member; import com.moabam.api.domain.member.Role; import com.moabam.api.dto.coupon.CouponResponse; import com.moabam.api.dto.coupon.CouponStatusRequest; @@ -40,9 +39,7 @@ import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.FilterProcessExtension; -import com.moabam.support.fixture.BugFixture; import com.moabam.support.fixture.CouponFixture; -import com.moabam.support.fixture.MemberFixture; @ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class CouponServiceTest { @@ -54,7 +51,7 @@ class CouponServiceTest { CouponManageService couponManageService; @Mock - MemberService memberService; + BugService bugService; @Mock CouponRepository couponRepository; @@ -321,11 +318,9 @@ void getByWalletIdAndMemberId_success() { @Test void use_success() { // Given - Member member = MemberFixture.member(BugFixture.zeroBug()); Coupon coupon = CouponFixture.coupon(CouponType.GOLDEN, 1000); CouponWallet couponWallet = CouponWallet.create(1L, coupon); - given(memberService.findMember(any(Long.class))).willReturn(member); given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) .willReturn(Optional.of(couponWallet)); diff --git a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java index 6abe441d..dca4b164 100644 --- a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -19,8 +19,8 @@ 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.BugHistory; import com.moabam.api.domain.bug.BugType; import com.moabam.api.domain.bug.repository.BugHistoryRepository; import com.moabam.api.domain.item.Inventory; @@ -47,6 +47,9 @@ class ItemServiceTest { @Mock MemberService memberService; + @Mock + BugService bugService; + @Mock ItemRepository itemRepository; @@ -106,11 +109,8 @@ void success() { itemService.purchaseItem(memberId, itemId, request); // Then - verify(memberService).findMember(memberId); - verify(itemRepository).findById(itemId); - verify(inventorySearchRepository).findOne(memberId, itemId); + verify(bugService).use(any(Member.class), any(BugType.class), anyInt()); verify(inventoryRepository).save(any(Inventory.class)); - verify(bugHistoryRepository).save(any(BugHistory.class)); } @DisplayName("해당 아이템이 존재하지 않으면 예외가 발생한다.") @@ -167,8 +167,6 @@ void success() { itemService.selectItem(memberId, itemId); // then - verify(inventorySearchRepository).findOne(memberId, itemId); - verify(inventorySearchRepository).findDefault(memberId, itemType); assertFalse(defaultInventory.isDefault()); assertTrue(inventory.isDefault()); } diff --git a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java index 83c98f41..d15ecdf1 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -19,6 +19,7 @@ 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.MemberService; import com.moabam.api.application.room.mapper.CertificationsMapper; @@ -51,6 +52,9 @@ class CertificationServiceTest { @Mock private MemberService memberService; + @Mock + private BugService bugService; + @Mock private RoomRepository roomRepository; @@ -161,7 +165,7 @@ void already_certified_room_routine_success() { certificationService.certifyRoom(certifyInfo); // then - assertThat(member1.getBug().getMorningBug()).isEqualTo(12); + verify(bugService).reward(any(Member.class), any(BugType.class), anyInt()); } @DisplayName("인증되지 않은 방에서 루틴 인증 후 방의 인증 성공") @@ -184,11 +188,7 @@ void not_certified_room_routine_success() { certificationService.certifyRoom(certifyInfo); // then - assertThat(member1.getBug().getMorningBug()).isEqualTo(12); - assertThat(member2.getBug().getMorningBug()).isEqualTo(12); - assertThat(member3.getBug().getMorningBug()).isEqualTo(12); - assertThat(member3.getBug().getNightBug()).isEqualTo(20); - assertThat(member3.getBug().getGoldenBug()).isEqualTo(30); + 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/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index b637e8f7..3cdd53d7 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -27,6 +27,7 @@ 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; @@ -54,6 +55,9 @@ class ItemControllerTest extends WithoutFilterSupporter { @MockBean MemberService memberService; + @MockBean + BugService bugService; + @Autowired ItemRepository itemRepository; From bec514405ef202cdcc4720124be638aebdac19c9 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Tue, 28 Nov 2023 16:28:19 +0900 Subject: [PATCH 096/185] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B2=84=EC=A0=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=B0=A9=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 아이템 테이블에 awakeImage, sleepImage 컬럼 추가 * feat: 방 레벨업 시 이미지 업데이트 로직 추가 * chore: 코드 제거 * test: 테스트 검증 수정 * chore: 이미지 URL에 작은 따옴표 제거 --- .../api/application/item/ItemMapper.java | 2 +- .../api/application/member/MemberService.java | 1 + .../api/application/room/SearchService.java | 8 ++-- .../application/room/mapper/RoomMapper.java | 4 +- .../java/com/moabam/api/domain/item/Item.java | 15 +++++-- .../com/moabam/api/domain/item/ItemImage.java | 28 +++++++++++++ .../com/moabam/api/domain/member/Member.java | 5 +-- .../java/com/moabam/api/domain/room/Room.java | 40 +++++++++++++++---- .../application/member/MemberServiceTest.java | 6 +-- .../com/moabam/api/domain/room/RoomTest.java | 22 +++++++++- .../presentation/MemberControllerTest.java | 4 +- .../moabam/support/fixture/ItemFixture.java | 22 ++++++++-- 12 files changed, 127 insertions(+), 30 deletions(-) create mode 100644 src/main/java/com/moabam/api/domain/item/ItemImage.java diff --git a/src/main/java/com/moabam/api/application/item/ItemMapper.java b/src/main/java/com/moabam/api/application/item/ItemMapper.java index f91591df..f2cabfff 100644 --- a/src/main/java/com/moabam/api/application/item/ItemMapper.java +++ b/src/main/java/com/moabam/api/application/item/ItemMapper.java @@ -20,7 +20,7 @@ public static ItemResponse toItemResponse(Item item) { .type(item.getType().name()) .category(item.getCategory().name()) .name(item.getName()) - .image(item.getImage()) + .image(item.getAwakeImage()) .level(item.getUnlockLevel()) .bugPrice(item.getBugPrice()) .goldenBugPrice(item.getGoldenBugPrice()) diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index b6505ce3..fb0e8637 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -107,6 +107,7 @@ private void validateNickname(String nickname) { private Member signUp(Long socialId) { Member member = MemberMapper.toMember(socialId); + return memberRepository.save(member); } diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index 9e62b9d0..fafe30d0 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -299,8 +299,8 @@ private List completedMembers( .findAny() .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); - String awakeImage = inventory.getItem().getImage(); - String sleepImage = inventory.getItem().getImage(); + String awakeImage = inventory.getItem().getAwakeImage(); + String sleepImage = inventory.getItem().getSleepImage(); int contributionPoint = calculateContributionPoint(member.getId(), participants, date); CertificationImagesResponse certificationImages = getCertificationImages(member.getId(), certifications); @@ -343,8 +343,8 @@ private List uncompletedMembers( .findAny() .orElseThrow(() -> new NotFoundException(INVENTORY_NOT_FOUND)); - String awakeImage = inventory.getItem().getImage(); - String sleepImage = inventory.getItem().getImage(); + String awakeImage = inventory.getItem().getAwakeImage(); + String sleepImage = inventory.getItem().getSleepImage(); int contributionPoint = calculateContributionPoint(memberId, participants, date); boolean isNotificationSent = knocks.contains(member.getId()); 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 index 21e86124..f80eec07 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -164,8 +164,8 @@ public static UnJoinedRoomCertificateRankResponse toUnJoinedRoomCertificateRankR .rank(rank) .memberId(member.getId()) .nickname(member.getNickname()) - .awakeImage(inventory.getItem().getImage()) - .sleepImage(inventory.getItem().getImage()) + .awakeImage(inventory.getItem().getAwakeImage()) + .sleepImage(inventory.getItem().getSleepImage()) .build(); } } diff --git a/src/main/java/com/moabam/api/domain/item/Item.java b/src/main/java/com/moabam/api/domain/item/Item.java index ad7bbd0d..6fefd03e 100644 --- a/src/main/java/com/moabam/api/domain/item/Item.java +++ b/src/main/java/com/moabam/api/domain/item/Item.java @@ -10,6 +10,7 @@ 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; @@ -44,8 +45,8 @@ public class Item extends BaseTimeEntity { @Column(name = "name", nullable = false) private String name; - @Column(name = "image", nullable = false) - private String image; + @Embedded + private ItemImage image; @Column(name = "bug_price", nullable = false) @ColumnDefault("0") @@ -60,7 +61,7 @@ public class Item extends BaseTimeEntity { private int unlockLevel; @Builder - private Item(ItemType type, ItemCategory category, String name, String image, int bugPrice, int goldenBugPrice, + private Item(ItemType type, ItemCategory category, String name, ItemImage image, int bugPrice, int goldenBugPrice, Integer unlockLevel) { this.type = requireNonNull(type); this.category = requireNonNull(category); @@ -107,4 +108,12 @@ private void validateBugTypeMatch(BugType bugType) { 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/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/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index 3cfc9f24..48486941 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -146,17 +146,16 @@ public void changeIntro(String intro) { public void changeProfileUri(String newProfileUri) { this.profileImage = requireNonNullElse(newProfileUri, profileImage); - this.profileImage = requireNonNullElse(newProfileUri, profileImage); } public void changeDefaultSkintUrl(Item item) throws NotFoundException { if (ItemType.MORNING.equals(item.getType())) { - this.morningImage = item.getImage(); + this.morningImage = item.getAwakeImage(); return; } if (ItemType.NIGHT.equals(item.getType())) { - this.nightImage = item.getImage(); + this.nightImage = item.getAwakeImage(); return; } diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java index 8a3d3dbe..00a7ac42 100644 --- a/src/main/java/com/moabam/api/domain/room/Room.java +++ b/src/main/java/com/moabam/api/domain/room/Room.java @@ -28,9 +28,15 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Room extends BaseTimeEntity { - private static final String ROOM_LEVEL_0_IMAGE = "'temptemp'"; - private static final String ROOM_LEVEL_10_IMAGE = "'temp'"; - private static final String ROOM_LEVEL_20_IMAGE = "'tempp'"; + private static final int LEVEL_5 = 5; + private static final int LEVEL_10 = 10; + private static final int LEVEL_20 = 20; + private static final int LEVEL_30 = 30; + private static final String ROOM_LEVEL_0_IMAGE = "https://image.moabam.com/moabam/default/room-level-00.png"; + private static final String ROOM_LEVEL_5_IMAGE = "https://image.moabam.com/moabam/default/room-level-05.png"; + private static final String ROOM_LEVEL_10_IMAGE = "https://image.moabam.com/moabam/default/room-level-10.png"; + private static final String ROOM_LEVEL_20_IMAGE = "https://image.moabam.com/moabam/default/room-level-20.png"; + private static final String ROOM_LEVEL_30_IMAGE = "https://image.moabam.com/moabam/default/room-level-30.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; @@ -72,7 +78,7 @@ public class Room extends BaseTimeEntity { @Column(name = "announcement", length = 100) private String announcement; - @ColumnDefault(ROOM_LEVEL_0_IMAGE) + @ColumnDefault("'" + ROOM_LEVEL_0_IMAGE + "'") @Column(name = "room_image", length = 500) private String roomImage; @@ -96,6 +102,28 @@ private Room(Long id, String title, String password, RoomType roomType, int cert public void levelUp() { this.level += 1; this.exp = 0; + upgradeRoomImage(this.level); + } + + public void upgradeRoomImage(int level) { + if (level == LEVEL_5) { + this.roomImage = ROOM_LEVEL_5_IMAGE; + return; + } + + if (level == LEVEL_10) { + this.roomImage = ROOM_LEVEL_10_IMAGE; + return; + } + + if (level == LEVEL_20) { + this.roomImage = ROOM_LEVEL_20_IMAGE; + return; + } + + if (level == LEVEL_30) { + this.roomImage = ROOM_LEVEL_30_IMAGE; + } } public void gainExp() { @@ -134,10 +162,6 @@ public void decreaseCurrentUserCount() { this.currentUserCount -= 1; } - public void upgradeRoomImage(String roomImage) { - this.roomImage = roomImage; - } - public void changeCertifyTime(int certifyTime) { this.certifyTime = validateCertifyTime(this.roomType, certifyTime); } diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index 3271f6f1..7a03c38d 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -181,7 +181,7 @@ void success(@WithMember AuthMember authMember) { Inventory morningSkin = InventoryFixture.inventory(searchId, morning); Inventory nightSkin = InventoryFixture.inventory(searchId, night); List memberInfos = MemberInfoSearchFixture - .myInfo(morningSkin.getItem().getImage(), nightSkin.getItem().getImage()); + .myInfo(morningSkin.getItem().getAwakeImage(), nightSkin.getItem().getAwakeImage()); given(memberSearchRepository.findMemberAndBadges(anyLong(), anyBoolean())) .willReturn(memberInfos); @@ -190,8 +190,8 @@ void success(@WithMember AuthMember authMember) { MemberInfoResponse memberInfoResponse = memberService.searchInfo(authMember, null); // then - assertThat(memberInfoResponse.birds()).containsEntry("MORNING", morningSkin.getItem().getImage()); - assertThat(memberInfoResponse.birds()).containsEntry("NIGHT", nightSkin.getItem().getImage()); + assertThat(memberInfoResponse.birds()).containsEntry("MORNING", morningSkin.getItem().getAwakeImage()); + assertThat(memberInfoResponse.birds()).containsEntry("NIGHT", nightSkin.getItem().getAwakeImage()); } } diff --git a/src/test/java/com/moabam/api/domain/room/RoomTest.java b/src/test/java/com/moabam/api/domain/room/RoomTest.java index 21889ac3..59a13a61 100644 --- a/src/test/java/com/moabam/api/domain/room/RoomTest.java +++ b/src/test/java/com/moabam/api/domain/room/RoomTest.java @@ -9,6 +9,7 @@ import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; +import com.moabam.support.fixture.RoomFixture; class RoomTest { @@ -25,7 +26,7 @@ void create_room_without_password_success() { // then assertThat(room.getPassword()).isNull(); - assertThat(room.getRoomImage()).isEqualTo("'temptemp'"); + 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); @@ -88,4 +89,23 @@ void night_time_validate_exception(int certifyTime) { .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_REQUEST_FIELD.getMessage()); } + + @DisplayName("레벨에 따른 이미지 업데이트") + @ParameterizedTest + @CsvSource({ + "5, https://image.moabam.com/moabam/default/room-level-05.png", + "10, https://image.moabam.com/moabam/default/room-level-10.png", + "20, https://image.moabam.com/moabam/default/room-level-20.png", + "30, https://image.moabam.com/moabam/default/room-level-30.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/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index fb4b4b9e..a2b7fd16 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -386,8 +386,8 @@ void search_friend_info_success() throws Exception { MockMvcResultMatchers.jsonPath("$.level").value(friend.getTotalCertifyCount() / LEVEL_DIVISOR), MockMvcResultMatchers.jsonPath("$.exp").value(friend.getTotalCertifyCount() % LEVEL_DIVISOR), - MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getImage()), - MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), + MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getAwakeImage()), + MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getAwakeImage()), MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), diff --git a/src/test/java/com/moabam/support/fixture/ItemFixture.java b/src/test/java/com/moabam/support/fixture/ItemFixture.java index c45d535d..3e65477a 100644 --- a/src/test/java/com/moabam/support/fixture/ItemFixture.java +++ b/src/test/java/com/moabam/support/fixture/ItemFixture.java @@ -4,6 +4,7 @@ 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 { @@ -16,27 +17,42 @@ public class ItemFixture { 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(MORNING_SANTA_SKIN_IMAGE); + .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(MORNING_KILLER_SKIN_IMAGE); + .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(NIGHT_MAGE_SKIN_IMAGE) + .image(image) .build(); } } From 448dc4535ee880b4891de8c5f3fed45e5a2256e6 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Tue, 28 Nov 2023 16:52:43 +0900 Subject: [PATCH 097/185] =?UTF-8?q?fix:=20no=20skin=20image=20=EB=B2=84?= =?UTF-8?q?=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#168)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 회원 로그인 시 기본 부엉이, 오목눈이 생성 기능 추가 및 테스트 코드 변경 * fix: 테스트 코드 변경 * refacotr: config 수정 * test: @BeforeAll Transaction적용 실패로 인한 merge 테스트 추가 * feat: 서비스 추가 * test: 기본 URL 변경 및 테스트 코드 수정 * style: 중복 코드 제거 --- .../api/application/member/MemberService.java | 4 +++- .../moabam/global/common/util/BaseImageUrl.java | 6 +++--- .../application/member/MemberServiceTest.java | 3 ++- .../api/presentation/MemberControllerTest.java | 16 ++++++++++++++++ 4 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index fb0e8637..295a6186 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -107,8 +107,10 @@ private void validateNickname(String nickname) { private Member signUp(Long socialId) { Member member = MemberMapper.toMember(socialId); + Member savedMember = memberRepository.save(member); + saveMyEgg(savedMember); - return memberRepository.save(member); + return savedMember; } private void saveMyEgg(Member member) { diff --git a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java index ac66f717..c430933a 100644 --- a/src/main/java/com/moabam/global/common/util/BaseImageUrl.java +++ b/src/main/java/com/moabam/global/common/util/BaseImageUrl.java @@ -14,7 +14,7 @@ public class BaseImageUrl { 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/오목눈이/기본/오목눈이알.png"; - public static final String DEFAULT_NIGHT_EGG_URL = "moabam/skins/부엉이/기본/부엉이알.png"; - public static final String MEMBER_PROFILE_URL = "moabam/default/기본회원프로필.png"; + 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/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index 7a03c38d..a7959641 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -97,6 +97,8 @@ void signUp_success() { 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); @@ -213,5 +215,4 @@ void modify_success_test(@WithMember AuthMember authMember) { () -> assertThat(member.getProfileImage()).isEqualTo("/main") ); } - } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index a2b7fd16..4c19555c 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -76,6 +76,8 @@ import com.moabam.support.fixture.RoomFixture; import com.moabam.support.fixture.TokenSaveValueFixture; +import jakarta.persistence.EntityManager; + @Transactional @SpringBootTest @AutoConfigureMockMvc @@ -128,6 +130,9 @@ class MemberControllerTest extends WithoutFilterSupporter { Member member; + @Autowired + EntityManager entityManager; + @BeforeAll void allSetUp() { restTemplateBuilder = new RestTemplateBuilder() @@ -143,6 +148,7 @@ void setUp() { RestTemplate restTemplate = restTemplateBuilder.build(); ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); + member = entityManager.merge(member); } @DisplayName("로그아웃 성공 테스트") @@ -270,6 +276,7 @@ void search_my_info_success() throws Exception { member.changeDefaultSkintUrl(night); member.changeDefaultSkintUrl(morning); + memberRepository.flush(); // expected mockMvc.perform(get("/members")) @@ -317,6 +324,11 @@ void search_my_info_with_no_badge_success() throws Exception { 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()) @@ -376,6 +388,10 @@ void search_friend_info_success() throws Exception { 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()) From e144759c62e7073c68e815ffcc557894b8cc9918 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 28 Nov 2023 16:54:11 +0900 Subject: [PATCH 098/185] =?UTF-8?q?hotfix:=20schema,=20item=20=EB=93=B1?= =?UTF-8?q?=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/sql/data.sql | 39 +++++ src/main/resources/sql/schema.sql | 247 ++++++++++++++++++++++++++++++ 2 files changed, 286 insertions(+) create mode 100644 src/main/resources/sql/data.sql create mode 100644 src/main/resources/sql/schema.sql diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql new file mode 100644 index 00000000..dfaec72c --- /dev/null +++ b/src/main/resources/sql/data.sql @@ -0,0 +1,39 @@ +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 ('MORNING', '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', 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/owl/glasses/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-closed', 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', 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/owl/scarf/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-closed', 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/scarf/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed', 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 ('MORNING', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed', 30, 15, 15, current_time()); diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql new file mode 100644 index 00000000..1c78b007 --- /dev/null +++ b/src/main/resources/sql/schema.sql @@ -0,0 +1,247 @@ +use moabam; + +create table badge +( + id bigint not null auto_increment, + member_id bigint not null, + type enum ('MORNING_ADULT','MORNING_BIRTH','NIGHT_ADULT','NIGHT_BIRTH') 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, + stock 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) not null 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), + 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); From 0326137b5496f3849c27ffcf9c29cc91f7c356de Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 28 Nov 2023 17:03:01 +0900 Subject: [PATCH 099/185] =?UTF-8?q?hotfix:=20config=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index ea25d857..0f4c27e5 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit ea25d85744c2e6fcedbdb66b34c08837d382814d +Subproject commit 0f4c27e5bb593650ad4733c98f5c27b54eebc3c2 From ecd65d21a9e67f5d908328ec30e4cb65a1a3edec Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 28 Nov 2023 17:13:32 +0900 Subject: [PATCH 100/185] =?UTF-8?q?hotfix:=20sql=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/sql/schema.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index 1c78b007..8f1f8cc4 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -1,4 +1,4 @@ -use moabam; +use moabam_dev; create table badge ( From eeda75115cb1b655f06c98415932e53ab885b74d Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 28 Nov 2023 18:07:00 +0900 Subject: [PATCH 101/185] =?UTF-8?q?hotfix:=20item=20inventory=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/coupon/CouponManageService.java | 3 +-- .../item/repository/ItemSearchRepository.java | 16 ++++++---------- src/main/resources/sql/data.sql | 8 ++++---- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index f4d1ef92..d4a070fc 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -4,7 +4,6 @@ import java.util.Optional; import java.util.Set; -import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import com.moabam.api.domain.coupon.Coupon; @@ -33,7 +32,7 @@ public class CouponManageService { private final CouponManageRepository couponManageRepository; private final CouponWalletRepository couponWalletRepository; - @Scheduled(fixedDelay = 1000) + // @Scheduled(fixedDelay = 1000) public void issue() { LocalDate now = clockHolder.date(); Optional isCoupon = couponRepository.findByStartAt(now); 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 index f2b0f150..14a62b7e 100644 --- a/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/item/repository/ItemSearchRepository.java @@ -1,7 +1,7 @@ package com.moabam.api.domain.item.repository; -import static com.moabam.api.domain.item.QInventory.*; -import static com.moabam.api.domain.item.QItem.*; +import static com.moabam.api.domain.item.QInventory.inventory; +import static com.moabam.api.domain.item.QItem.item; import java.util.List; @@ -10,7 +10,6 @@ import com.moabam.api.domain.item.Item; import com.moabam.api.domain.item.ItemType; import com.moabam.global.common.util.DynamicQuery; -import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; @@ -24,10 +23,12 @@ public class ItemSearchRepository { public List findNotPurchasedItems(Long memberId, ItemType type) { return jpaQueryFactory.selectFrom(item) .leftJoin(inventory) - .on(inventory.item.id.eq(item.id)) + .on(inventory.item.id.eq(item.id) + .and(inventory.memberId.eq(memberId))) .where( DynamicQuery.generateEq(type, item.type::eq), - DynamicQuery.generateEq(memberId, this::filterByMemberId)) + inventory.memberId.isNull() + ) .orderBy( item.unlockLevel.asc(), item.bugPrice.asc(), @@ -35,9 +36,4 @@ public List findNotPurchasedItems(Long memberId, ItemType type) { item.name.asc()) .fetch(); } - - private BooleanExpression filterByMemberId(Long memberId) { - return inventory.memberId.isNull() - .or(inventory.memberId.ne(memberId)); - } } diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql index dfaec72c..7ed20150 100644 --- a/src/main/resources/sql/data.sql +++ b/src/main/resources/sql/data.sql @@ -11,7 +11,7 @@ values ('MORNING', 'SKIN', '오목눈이', 'https://image.moabam.com/moabam/skin '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 ('MORNING', 'SKIN', '부엉이', 'https://image.moabam.com/moabam/skins/owl/default/eyes-opened.png', +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) @@ -19,7 +19,7 @@ values ('MORNING', 'SKIN', '안경 오목눈이', 'https://image.moabam.com/moab 'https://image.moabam.com/moabam/skins/omok/glasses/eyes-closed', 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/owl/glasses/eyes-opened.png', +values ('NIGHT', 'SKIN', '안경 부엉이', 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-opened.png', 'https://image.moabam.com/moabam/skins/owl/glasses/eyes-closed', 10, 5, 5, current_time()); insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) @@ -27,7 +27,7 @@ values ('MORNING', 'SKIN', '목도리 오목눈이', 'https://image.moabam.com/m 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed', 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/owl/scarf/eyes-opened.png', +values ('NIGHT', 'SKIN', '목도리 부엉이', 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-opened.png', 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-closed', 20, 10, 10, current_time()); insert into item (type, category, name, awake_image, sleep_image, bug_price, golden_bug_price, unlock_level, created_at) @@ -35,5 +35,5 @@ values ('MORNING', 'SKIN', '산타 오목눈이', 'https://image.moabam.com/moab 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed', 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 ('MORNING', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png', +values ('NIGHT', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed', 30, 15, 15, current_time()); From b9d23a62c018110ef94179ff812f373d2f50b844 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Tue, 28 Nov 2023 18:51:43 +0900 Subject: [PATCH 102/185] =?UTF-8?q?hotfix:=20config=20admin=20key=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- src/main/resources/sql/data.sql | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/resources/config b/src/main/resources/config index 0f4c27e5..5e6c60e7 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 0f4c27e5bb593650ad4733c98f5c27b54eebc3c2 +Subproject commit 5e6c60e731be0f52ae7ffe2ae5094e1074b36233 diff --git a/src/main/resources/sql/data.sql b/src/main/resources/sql/data.sql index 7ed20150..7030418f 100644 --- a/src/main/resources/sql/data.sql +++ b/src/main/resources/sql/data.sql @@ -1,9 +1,9 @@ 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', +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', +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) @@ -31,8 +31,8 @@ values ('NIGHT', 'SKIN', '목도리 부엉이', 'https://image.moabam.com/moabam 'https://image.moabam.com/moabam/skins/owl/scarf/eyes-closed', 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/scarf/eyes-opened.png', - 'https://image.moabam.com/moabam/skins/omok/scarf/eyes-closed', 30, 15, 15, current_time()); +values ('MORNING', 'SKIN', '산타 오목눈이', 'https://image.moabam.com/moabam/skins/omok/santa/eyes-opened.png', + 'https://image.moabam.com/moabam/skins/omok/santa/eyes-closed', 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', From eb6b566e301dff6138761f0546bfd5d3925866cb Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 02:21:16 +0900 Subject: [PATCH 103/185] hotfix: config sql init none --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 5e6c60e7..57afabc2 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 5e6c60e731be0f52ae7ffe2ae5094e1074b36233 +Subproject commit 57afabc2bb308479e7b6cae9109be2d8e37bfdd5 From bcda782ff3cf0d340c5da5338da660ee79a0bdf0 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 02:29:47 +0900 Subject: [PATCH 104/185] hotfix: config sql init never --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 57afabc2..8c1c40f6 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 57afabc2bb308479e7b6cae9109be2d8e37bfdd5 +Subproject commit 8c1c40f6140b1ef9e302fc3b4c440866a8669d67 From 25932fbeaaf80f7d36318b4ef60bc1f67ed2ea55 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Wed, 29 Nov 2023 15:19:39 +0900 Subject: [PATCH 105/185] =?UTF-8?q?refactor:=20=EC=8B=A4=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=20=EC=84=A0=EC=B0=A9=EC=88=9C=20=EC=BF=A0=ED=8F=B0=20=EB=B0=9C?= =?UTF-8?q?=EA=B8=89=20=EA=B8=B0=EB=8A=A5=20=EB=A6=AC=ED=8C=A9=ED=84=B0?= =?UTF-8?q?=EB=A7=81=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: ZSET popMin -> range로 변경 * refactor: 쿠폰 관리 저장소 popMin -> range로 변경 * feat: 쿠폰 발급 결과 FCM 알림 전송 기능 구현 및 테스트 * feat: ZSET size 반환 기능 구현 및 테스트 * feat: 쿠폰 대기열 사이즈를 반환하는 기능 구현 및 테스트 * test: 테스트 코드 체크 스타일 수정 * fix: Import 에러 해결 * refactor: 쿠폰 발급 현재 위치 기록 변경 * refactor: 쿠폰 대기열 크기 조회 기능 삭제 * refactor: addIfAbsent 기능 수정 * test: 레디스 SORTED SET 명령어 테스트 Disabled * refactor: 쿠폰 발급 및 발행 기능 수정 * test: 쿠폰 랭킹 조회 기능 테스트 추가 --- .../coupon/CouponManageService.java | 69 +++++----- .../api/application/coupon/CouponService.java | 2 +- .../notification/NotificationService.java | 11 +- .../repository/CouponManageRepository.java | 43 ++---- .../coupon/repository/CouponRepository.java | 4 +- .../api/infrastructure/fcm/FcmService.java | 16 +-- .../redis/ZSetRedisRepository.java | 24 ++-- .../api/presentation/CouponController.java | 2 +- .../global/config/EmbeddedRedisConfig.java | 6 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 20 +-- .../coupon/CouponManageServiceTest.java | 130 +++++++----------- .../application/coupon/CouponServiceTest.java | 2 +- .../notification/NotificationServiceTest.java | 39 +++++- .../CouponManageRepositoryTest.java | 97 +++---------- .../redis/ZSetRedisRepositoryTest.java | 50 +++---- .../presentation/CouponControllerTest.java | 6 +- .../moabam/support/fixture/CouponFixture.java | 46 ++++--- 18 files changed, 260 insertions(+), 309 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index d4a070fc..4d46171a 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -4,14 +4,15 @@ 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.auth.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.model.ErrorMessage; @@ -24,67 +25,69 @@ @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 static final long ISSUE_FIRST = 0; private final ClockHolder clockHolder; + private final NotificationService notificationService; private final CouponRepository couponRepository; private final CouponManageRepository couponManageRepository; private final CouponWalletRepository couponWalletRepository; - // @Scheduled(fixedDelay = 1000) + private long current = ISSUE_FIRST; + + @Scheduled(cron = "0 0 0 * * *") + public void init() { + current = ISSUE_FIRST; + } + + @Scheduled(fixedDelay = 1000) public void issue() { LocalDate now = clockHolder.date(); - Optional isCoupon = couponRepository.findByStartAt(now); + Optional optionalCoupon = couponRepository.findByStartAt(now); - if (!canIssue(isCoupon)) { + if (optionalCoupon.isEmpty()) { return; } - Coupon coupon = isCoupon.get(); - Set membersId = couponManageRepository.popMinQueue(coupon.getName(), ISSUE_SIZE); + Coupon coupon = optionalCoupon.get(); + String couponName = coupon.getName(); + int max = coupon.getStock(); - membersId.forEach(memberId -> { - int nextStock = couponManageRepository.increaseIssuedStock(coupon.getName()); + Set membersId = couponManageRepository.rangeQueue(couponName, current, current + ISSUE_SIZE); - if (coupon.getStock() < nextStock) { - return; + for (Long memberId : membersId) { + int rank = couponManageRepository.rankQueue(couponName, memberId); + + if (max < rank) { + notificationService.sendCouponIssueResult(memberId, couponName, FAIL_ISSUE_BODY); + continue; } - CouponWallet couponWallet = CouponWallet.create(memberId, coupon); - couponWalletRepository.save(couponWallet); - }); + couponWalletRepository.save(CouponWallet.create(memberId, coupon)); + notificationService.sendCouponIssueResult(memberId, couponName, SUCCESS_ISSUE_BODY); + current++; + } } - public void register(AuthMember authMember, String couponName) { + public void registerQueue(Long memberId, String couponName) { double registerTime = System.currentTimeMillis(); - validateRegister(couponName); - couponManageRepository.addIfAbsentQueue(couponName, authMember.id(), registerTime); + validateRegisterQueue(couponName); + couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime); } - public void deleteCouponManage(String couponName) { + public void deleteQueue(String couponName) { couponManageRepository.deleteQueue(couponName); - couponManageRepository.deleteIssuedStock(couponName); } - private void validateRegister(String couponName) { + private void validateRegisterQueue(String couponName) { LocalDate now = clockHolder.date(); - Optional coupon = couponRepository.findByStartAt(now); - if (coupon.isEmpty() || !coupon.get().getName().equals(couponName)) { + if (!couponRepository.existsByNameAndStartAt(couponName, now)) { throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); } } - - private boolean canIssue(Optional coupon) { - if (coupon.isEmpty()) { - return false; - } - - Coupon currentCoupon = coupon.get(); - int currentStock = couponManageRepository.getIssuedStock(currentCoupon.getName()); - int maxStock = currentCoupon.getStock(); - - return currentStock < maxStock; - } } diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 3653802c..a25af06e 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -80,7 +80,7 @@ public void delete(AuthMember admin, Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); couponRepository.delete(coupon); - couponManageService.deleteCouponManage(coupon.getName()); + couponManageService.deleteQueue(coupon.getName()); } public CouponResponse getById(Long couponId) { diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index bcbec2af..7eedd953 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -19,6 +19,7 @@ import com.moabam.global.auth.model.AuthMember; 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; @@ -42,12 +43,18 @@ public class NotificationService { public void sendKnock(AuthMember member, Long targetId, Long roomId) { roomService.validateRoomById(roomId); validateConflictKnock(member.id(), targetId, roomId); + String fcmToken = fcmService.findTokenByMemberId(targetId) + .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); - String fcmToken = fcmService.findTokenByMemberId(targetId); fcmService.sendAsync(fcmToken, String.format(KNOCK_BODY, member.nickname())); notificationRepository.saveKnock(member.id(), targetId, roomId); } + public void sendCouponIssueResult(Long memberId, String couponName, String body) { + String fcmToken = fcmService.findTokenByMemberId(memberId).orElse(null); + fcmService.sendAsync(fcmToken, String.format(body, couponName)); + } + @Scheduled(cron = "0 50 * * * *") public void sendCertificationTime() { int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; @@ -56,7 +63,7 @@ public void sendCertificationTime() { participants.parallelStream().forEach(participant -> { String roomTitle = participant.getRoom().getTitle(); String notificationBody = String.format(CERTIFY_TIME_BODY, roomTitle); - String fcmToken = fcmService.findTokenByMemberId(participant.getMemberId()); + String fcmToken = fcmService.findTokenByMemberId(participant.getMemberId()).orElse(null); fcmService.sendAsync(fcmToken, notificationBody); }); } 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 index 22f7df8f..5dc248fe 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -16,48 +16,35 @@ @RequiredArgsConstructor public class CouponManageRepository { - private static final String STOCK_KEY = "%s_INCR"; + 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); + zSetRedisRepository.addIfAbsent( + requireNonNull(couponName), + requireNonNull(memberId), + registerTime, + EXPIRE_DAYS + ); } - public Set popMinQueue(String couponName, long count) { + public Set rangeQueue(String couponName, long start, long end) { return zSetRedisRepository - .popMin(requireNonNull(couponName), count) + .range(requireNonNull(couponName), start, end) .stream() - .map(tuple -> (Long)tuple.getValue()) + .map(Long.class::cast) .collect(Collectors.toSet()); } - public void deleteQueue(String couponName) { - valueRedisRepository.delete(requireNonNull(couponName)); - } - - public int increaseIssuedStock(String couponName) { - String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - - return valueRedisRepository - .increment(requireNonNull(stockKey)) + public int rankQueue(String couponName, Long memberId) { + return zSetRedisRepository + .rank(requireNonNull(couponName), requireNonNull(memberId)) .intValue(); } - public int getIssuedStock(String couponName) { - String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - String stockValue = valueRedisRepository.get(requireNonNull(stockKey)); - - if (stockValue == null) { - return 0; - } - - return Integer.parseInt(stockValue); - } - - public void deleteIssuedStock(String couponName) { - String stockKey = String.format(STOCK_KEY, requireNonNull(couponName)); - valueRedisRepository.delete(requireNonNull(stockKey)); + public void deleteQueue(String couponName) { + valueRedisRepository.delete(requireNonNull(couponName)); } } 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 index 38b9dbe1..a9e5c0a6 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java @@ -9,9 +9,11 @@ public interface CouponRepository extends JpaRepository { - boolean existsByName(String name); + boolean existsByName(String couponName); boolean existsByStartAt(LocalDate startAt); Optional findByStartAt(LocalDate startAt); + + boolean existsByNameAndStartAt(String couponName, LocalDate startAt); } diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java index 1ee135c0..1d8dd25b 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -1,13 +1,13 @@ 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 com.moabam.global.auth.model.AuthMember; -import com.moabam.global.error.exception.NotFoundException; -import com.moabam.global.error.model.ErrorMessage; import lombok.RequiredArgsConstructor; @@ -32,10 +32,8 @@ public void deleteTokenByMemberId(Long memberId) { fcmRepository.deleteTokenByMemberId(memberId); } - public String findTokenByMemberId(Long targetId) { - validateToken(targetId); - - return fcmRepository.findTokenByMemberId(targetId); + public Optional findTokenByMemberId(Long targetId) { + return Optional.ofNullable(fcmRepository.findTokenByMemberId(targetId)); } public void sendAsync(String fcmToken, String notificationBody) { @@ -46,10 +44,4 @@ public void sendAsync(String fcmToken, String notificationBody) { firebaseMessaging.sendAsync(message); } } - - private void validateToken(Long memberId) { - if (!fcmRepository.existsTokenByMemberId(memberId)) { - throw new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN); - } - } } diff --git a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java index 9ac7c607..e116d764 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -2,10 +2,10 @@ 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.TypedTuple; import org.springframework.stereotype.Repository; import lombok.RequiredArgsConstructor; @@ -16,17 +16,23 @@ public class ZSetRedisRepository { private final RedisTemplate redisTemplate; - public void addIfAbsent(String key, Object value, double score) { - if (redisTemplate.opsForZSet().score(key, value) == null) { - redisTemplate - .opsForZSet() - .add(requireNonNull(key), requireNonNull(value), score); - } + 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 Set> popMin(String key, long count) { + public Long rank(String key, Object value) { return redisTemplate .opsForZSet() - .popMin(key, count); + .rank(key, value); } } diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index 8de012fa..4668e431 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -58,7 +58,7 @@ public List getAllByStatus(@Valid @RequestBody CouponStatusReque @PostMapping("/coupons") @ResponseStatus(HttpStatus.OK) public void registerQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { - couponManageService.register(authMember, couponName); + couponManageService.registerQueue(authMember.id(), couponName); } @GetMapping({"/my-coupons", "/my-coupons/{couponId}"}) diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 02268602..32ad60ec 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -36,8 +36,10 @@ public class EmbeddedRedisConfig { private int availablePort; private RedisServer redisServer; - public EmbeddedRedisConfig(@Value("${spring.data.redis.port}") int redisPort, - @Value("${spring.data.redis.host}") String redisHost) { + public EmbeddedRedisConfig( + @Value("${spring.data.redis.port}") int redisPort, + @Value("${spring.data.redis.host}") String redisHost + ) { this.redisPort = redisPort; this.redisHost = redisHost; diff --git a/src/main/resources/config b/src/main/resources/config index 8c1c40f6..0f4c27e5 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 8c1c40f6140b1ef9e302fc3b4c440866a8669d67 +Subproject commit 0f4c27e5bb593650ad4733c98f5c27b54eebc3c2 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index a94f14c3..0b834827 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/35 HTTP/1.1
+
DELETE /admins/coupons/34 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/23 HTTP/1.1
+
GET /coupons/22 HTTP/1.1
 Host: localhost:8080
@@ -543,7 +543,7 @@

응답

Content-Length: 198 { - "id" : 23, + "id" : 22, "adminName" : "1admin", "name" : "couponName", "description" : "", @@ -593,7 +593,7 @@

응답

Content-Length: 199 [ { - "id" : 24, + "id" : 23, "adminName" : "1admin", "name" : "coupon1", "description" : "", @@ -675,31 +675,31 @@

응답

Content-Length: 472 [ { - "id" : 18, + "id" : 17, "name" : "c1", "description" : "", "point" : 10, "type" : "MORNING" }, { - "id" : 19, + "id" : 18, "name" : "c2", "description" : "", "point" : 10, "type" : "MORNING" }, { - "id" : 20, + "id" : 19, "name" : "c3", "description" : "", "point" : 10, "type" : "MORNING" }, { - "id" : 21, + "id" : 20, "name" : "c4", "description" : "", "point" : 10, "type" : "MORNING" }, { - "id" : 22, + "id" : 21, "name" : "c5", "description" : "", "point" : 10, @@ -746,7 +746,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index e347aba6..bc0de6cf 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -5,28 +5,27 @@ import static org.mockito.BDDMockito.*; import java.time.LocalDate; -import java.util.HashSet; 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.auth.model.AuthMember; -import com.moabam.global.auth.model.AuthorizationThreadLocal; 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.CouponFixture; @@ -36,6 +35,9 @@ class CouponManageServiceTest { @InjectMocks CouponManageService couponManageService; + @Mock + NotificationService notificationService; + @Mock CouponRepository couponRepository; @@ -48,24 +50,33 @@ class CouponManageServiceTest { @Mock ClockHolder clockHolder; - @DisplayName("쿠폰 발행이 성공적으로 된다.") + @DisplayName("쿠폰 관리 인덱스를 성공적으로 초기화한다.") @Test - void issue_all_success() { + void init_success() { + // When & Then + assertThatNoException().isThrownBy(() -> couponManageService.init()); + } + + @DisplayName("10명의 사용자가 쿠폰 발행을 성공적으로 한.") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") + @ParameterizedTest + void issue_all_success(Set values) { // Given Coupon coupon = CouponFixture.coupon(1000, 100); - Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); - given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); - given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); - given(couponManageRepository.increaseIssuedStock(any(String.class))).willReturn(99); + given(couponManageRepository.rankQueue(any(String.class), any(Long.class))).willReturn(coupon.getStock()); + given(couponManageRepository.rangeQueue(any(String.class), any(long.class), any(long.class))) + .willReturn(values); // 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("발행 가능한 쿠폰이 없다.") @@ -79,136 +90,87 @@ void issue_notStartAt() { couponManageService.issue(); // Then - verify(couponManageRepository, times(0)).getIssuedStock(any(String.class)); - verify(couponManageRepository, times(0)).popMinQueue(any(String.class), any(long.class)); - verify(couponManageRepository, times(0)).increaseIssuedStock(any(String.class)); 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("해당 쿠폰은 재고가 마감된 쿠폰이다.") - @Test - void issue_stockEnd() { + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") + @ParameterizedTest + void issue_stockEnd(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.getIssuedStock(any(String.class))).willReturn(coupon.getStock()); + given(couponManageRepository.rankQueue(any(String.class), any(Long.class))).willReturn(coupon.getStock() + 1); + given(couponManageRepository.rangeQueue(any(String.class), any(long.class), any(long.class))) + .willReturn(values); // When couponManageService.issue(); // Then - verify(couponManageRepository, times(0)).popMinQueue(any(String.class), any(long.class)); - verify(couponManageRepository, times(0)).increaseIssuedStock(any(String.class)); verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); + verify(notificationService, times(10)) + .sendCouponIssueResult(any(Long.class), any(String.class), any(String.class)); } - @DisplayName("대기열에 남은 인원이 모두 발급받지 못한다.") - @Test - void issue_queue_stockENd() { - // Given - Coupon coupon = CouponFixture.coupon(1000, 100); - Set membersId = new HashSet<>(Set.of(1L, 2L, 3L, 4L, 5L, 6L, 7L, 8L, 9L, 10L)); - - given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); - given(couponManageRepository.getIssuedStock(any(String.class))).willReturn(10); - given(couponManageRepository.popMinQueue(any(String.class), any(long.class))).willReturn(membersId); - given(couponManageRepository.increaseIssuedStock(any(String.class))).willReturn(101); - - // When - couponManageService.issue(); - - // Then - verify(couponWalletRepository, times(0)).save(any(CouponWallet.class)); - } - - @WithMember @DisplayName("쿠폰 발급 요청을 성공적으로 큐에 등록한다. - Void") @Test - void register_success() { + void registerQueue_success() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(); given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); + given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(true); // When - couponManageService.register(member, coupon.getName()); + couponManageService.registerQueue(1L, coupon.getName()); // Then verify(couponManageRepository).addIfAbsentQueue(any(String.class), any(Long.class), any(double.class)); } - @WithMember @DisplayName("금일 발급이 가능한 쿠폰이 없다. - BadRequestException") @Test - void register_StartAt_BadRequestException() { + void registerQueue_No_BadRequestException() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.empty()); + given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> couponManageService.register(member, "couponName")) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); - } - - @WithMember - @DisplayName("금일 발급 가능한 쿠폰의 이름과 일치하지 않는다. - BadRequestException") - @Test - void register_Name_BadRequestException() { - // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - Coupon coupon = CouponFixture.coupon(); - - given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); - - // When & Then - assertThatThrownBy(() -> couponManageService.register(member, "Coupon Cannot Be Issued Today")) + assertThatThrownBy(() -> couponManageService.registerQueue(1L, "couponName")) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } @DisplayName("쿠폰 대기열과 발행된 재고가 정상적으로 삭제된다.") @Test - void deleteCouponManage_success() { + void deleteQueue_success() { // Given String couponName = "couponName"; // When - couponManageService.deleteCouponManage(couponName); + couponManageService.deleteQueue(couponName); // Then verify(couponManageRepository).deleteQueue(couponName); - verify(couponManageRepository).deleteIssuedStock(couponName); } @DisplayName("쿠폰 대기열이 정상적으로 삭제되지 않는다.") @Test - void deleteCouponManage_Queue_NullPointerException() { - // Given - willThrow(NullPointerException.class).given(couponManageRepository).deleteQueue(any(String.class)); - - // When & Then - assertThatThrownBy(() -> couponManageService.deleteCouponManage("null")) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("쿠폰의 발행된 재고가 정상적으로 삭제되지 않는다.") - @Test - void deleteCouponManage_Stock_NullPointerException() { + void deleteQueue_NullPointerException() { // Given - willDoNothing().given(couponManageRepository).deleteQueue(any(String.class)); - willThrow(NullPointerException.class).given(couponManageRepository).deleteIssuedStock(any(String.class)); + willThrow(NullPointerException.class) + .given(couponManageRepository) + .deleteQueue(any(String.class)); // When & Then - assertThatThrownBy(() -> couponManageService.deleteCouponManage("null")) + assertThatThrownBy(() -> couponManageService.deleteQueue("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 index 86db23f0..6e1efbe9 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -202,7 +202,7 @@ void delete_success() { // Then verify(couponRepository).delete(coupon); - verify(couponManageService).deleteCouponManage(any(String.class)); + verify(couponManageService).deleteQueue(any(String.class)); } @WithMember(role = Role.USER) diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index a85c0fc9..9b411f8a 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -5,6 +5,7 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -50,6 +51,8 @@ class NotificationServiceTest { @Mock ClockHolder clockHolder; + String successIssueResult = "%s 쿠폰 발행을 성공했습니다. 축하드립니다!"; + @WithMember @DisplayName("상대에게 콕 알림을 성공적으로 보낸다. - Void") @Test @@ -58,7 +61,7 @@ void sendKnock_success() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); - given(fcmService.findTokenByMemberId(any(Long.class))).willReturn("FCM-TOKEN"); + 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); @@ -92,10 +95,9 @@ void sendKnock_FcmToken_NotFoundException() { AuthMember member = AuthorizationThreadLocal.getAuthMember(); willDoNothing().given(roomService).validateRoomById(any(Long.class)); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) .willReturn(false); - given(fcmService.findTokenByMemberId(any(Long.class))) - .willThrow(new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); // When & Then assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) @@ -120,13 +122,39 @@ void sendKnock_ConflictException() { .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)); + } + + @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)); + } + @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("FCM-TOKEN"); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.of("FCM-TOKEN")); given(clockHolder.times()).willReturn(LocalDateTime.now()); // When @@ -136,14 +164,13 @@ void sendCertificationTime_success(List participants) { verify(fcmService, times(3)).sendAsync(any(String.class), any(String.class)); } - @WithMember @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(null); + given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); given(clockHolder.times()).willReturn(LocalDateTime.now()); // When 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 index 2dd66862..f63cc680 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponManageRepositoryTest.java @@ -14,7 +14,6 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.redis.core.ZSetOperations.TypedTuple; import com.moabam.api.infrastructure.redis.ValueRedisRepository; import com.moabam.api.infrastructure.redis.ZSetRedisRepository; @@ -38,7 +37,7 @@ void addIfAbsentQueue_success() { couponManageRepository.addIfAbsentQueue("couponName", 1L, 1); // Then - verify(zSetRedisRepository).addIfAbsent(any(String.class), any(Long.class), any(double.class)); + verify(zSetRedisRepository).addIfAbsent(any(String.class), any(Long.class), any(double.class), any(int.class)); } @DisplayName("쿠폰명이 Null인 대기열에 사용자를 등록한다.- NullPointerException") @@ -57,116 +56,64 @@ void addIfAbsentQueue_memberId_NullPointerException() { .isInstanceOf(NullPointerException.class); } - @DisplayName("쿠폰 대기열에서 성공적으로 10명을 꺼내고 삭제한다.") - @MethodSource("com.moabam.support.fixture.CouponFixture#provideTypedTuples") + @DisplayName("쿠폰 대기열에서 성공적으로 10명을 조회한다. - Set") + @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Object") @ParameterizedTest - void popMinQueue_success(Set> tuples) { + void range_success(Set values) { // Given - given(zSetRedisRepository.popMin(any(String.class), any(long.class))).willReturn(tuples); + given(zSetRedisRepository.range(any(String.class), any(long.class), any(long.class))).willReturn(values); // When - Set actual = couponManageRepository.popMinQueue("couponName", 10); + Set actual = couponManageRepository.rangeQueue("couponName", 0, 10); // Then assertThat(actual).hasSize(10); } - @DisplayName("쿠폰명이 Null인 대기열에서 사용자를 꺼낸다. - NullPointerException") + @DisplayName("쿠폰명이 Null인 대기열에서 사용자를 조회한다. - NullPointerException") @Test - void popMinQueue_NullPointerException() { + void range_NullPointerException() { // When & Then - assertThatThrownBy(() -> couponManageRepository.popMinQueue(null, 10)) + assertThatThrownBy(() -> couponManageRepository.rangeQueue(null, 0, 10)) .isInstanceOf(NullPointerException.class); } - @DisplayName("쿠폰 대기열을 성공적으로 삭제한다. - Void") - @Test - void deleteQueue_success() { - // When - couponManageRepository.deleteQueue("couponName"); - - // Then - verify(valueRedisRepository).delete(any(String.class)); - } - - @DisplayName("쿠폰명이 Null인 대기열을 삭제한다. - NullPointerException") + @DisplayName("특정 사용자의 쿠폰 순위를 정상적으로 조회한다. - int") @Test - void deleteQueue_NullPointerException() { - // When & Then - assertThatThrownBy(() -> couponManageRepository.deleteQueue(null)) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("쿠폰의 할당된 재고를 성공적으로 증가시킨다. - int") - @Test - void increaseIssuedStock_success() { + void rankQueue_success() { // Given - given(valueRedisRepository.increment(any(String.class))).willReturn(77L); + given(zSetRedisRepository.rank(any(String.class), any(Long.class))).willReturn(1L); // When - int actual = couponManageRepository.increaseIssuedStock("couponName"); - - // Then - assertThat(actual).isEqualTo(77); - } - - @DisplayName("쿠폰명이 Null인 쿠폰의 할당된 재고를 증가시킨다. - NullPointerException") - @Test - void increaseIssuedStock_NullPointerException() { - // When & Then - assertThatThrownBy(() -> couponManageRepository.increaseIssuedStock(null)) - .isInstanceOf(NullPointerException.class); - } - - @DisplayName("쿠폰의 현재 할당된 재고를 성공적으로 조회한다. - int") - @Test - void getIssuedStock_success() { - // Given - given(valueRedisRepository.get(any(String.class))).willReturn("1"); - - // When - int actual = couponManageRepository.getIssuedStock("couponName"); + int actual = couponManageRepository.rankQueue("couponName", 1L); // Then assertThat(actual).isEqualTo(1); } - @DisplayName("쿠폰의 현재 할당된 재고가 없어서 0이 조회된다. - int") - @Test - void getIssuedStock_zero() { - // Given - given(valueRedisRepository.get(any(String.class))).willReturn(null); - - // When - int actual = couponManageRepository.getIssuedStock("couponName"); - - // Then - assertThat(actual).isZero(); - } - - @DisplayName("쿠폰명이 Null인 쿠폰의 할당된 재고를 조회한다. - NullPointerException") + @DisplayName("쿠폰명이 Null인 특정 사용자 쿠폰 순위를 조회한다. - int") @Test - void getIssuedStock_NullPointerException() { + void rankQueue_NullPointerException() { // When & Then - assertThatThrownBy(() -> couponManageRepository.getIssuedStock(null)) + assertThatThrownBy(() -> couponManageRepository.rankQueue(null, 1L)) .isInstanceOf(NullPointerException.class); } - @DisplayName("할당된 쿠폰 재고를 성공적으로 삭제한다. - Void") + @DisplayName("쿠폰 대기열을 성공적으로 삭제한다. - Void") @Test - void deleteIssuedStock_success() { + void deleteQueue_success() { // When - couponManageRepository.deleteIssuedStock("couponName"); + couponManageRepository.deleteQueue("couponName"); // Then verify(valueRedisRepository).delete(any(String.class)); } - @DisplayName("쿠폰명이 Null인 할당된 쿠폰 재고를 삭제한다. - NullPointerException") + @DisplayName("쿠폰명이 Null인 대기열을 삭제한다. - NullPointerException") @Test - void deleteIssuedStock_NullPointerException() { + void deleteQueue_NullPointerException() { // When & Then - assertThatThrownBy(() -> couponManageRepository.deleteIssuedStock(null)) + assertThatThrownBy(() -> couponManageRepository.deleteQueue(null)) .isInstanceOf(NullPointerException.class); } } diff --git a/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java index cfc29bb4..072dcf25 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/ZSetRedisRepositoryTest.java @@ -11,7 +11,6 @@ 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.TypedTuple; import com.moabam.global.config.EmbeddedRedisConfig; @@ -29,6 +28,7 @@ class ZSetRedisRepositoryTest { String key = "key"; Long value = 1L; + int expireDays = 2; @AfterEach void afterEach() { @@ -37,75 +37,75 @@ void afterEach() { } } + @Disabled @DisplayName("레디스의 SortedSet 데이터가 성공적으로 저장된다. - Void") @Test void addIfAbsent_success() { // When - zSetRedisRepository.addIfAbsent(key, value, 1); + 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); - zSetRedisRepository.addIfAbsent(key, value, 5); + zSetRedisRepository.addIfAbsent(key, value, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value, 5, expireDays); // Then assertThat(redisTemplate.opsForZSet().score(key, value)).isEqualTo(1); } @Disabled - @DisplayName("저장된 데이터와 동일한 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") + @DisplayName("저장된 데이터와 동일한 갯수만큼 조회한다. - Set") @Test - void popMin_same_success() { + void range_same_success() { // Given - zSetRedisRepository.addIfAbsent(key, value + 1, 1); - zSetRedisRepository.addIfAbsent(key, value + 2, 2); - zSetRedisRepository.addIfAbsent(key, value + 3, 3); + 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.popMin(key, 3); + Set actual = zSetRedisRepository.range(key, 0, 3); // Then assertThat(actual).hasSize(3); - assertThat(valueRedisRepository.hasKey(key)).isFalse(); } @Disabled - @DisplayName("저장된 데이터보다 많은 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") + @DisplayName("저장된 데이터보다 많은 갯수만큼 조회한다. - Set") @Test - void popMin_more_success() { + void range_more_success() { // Given - zSetRedisRepository.addIfAbsent(key, value + 1, 1); - zSetRedisRepository.addIfAbsent(key, value + 2, 2); + zSetRedisRepository.addIfAbsent(key, value + 1, 1, expireDays); + zSetRedisRepository.addIfAbsent(key, value + 2, 2, expireDays); // When - Set> actual = zSetRedisRepository.popMin(key, 3); + Set actual = zSetRedisRepository.range(key, 0, 3); // Then assertThat(actual).hasSize(2); } @Disabled - @DisplayName("저장된 데이터보다 더 적은 갯수만큼의 반환과 삭제를 성공적으로 시도한다. - Set>") + @DisplayName("저장된 데이터보다 더 적은 갯수만큼 조회한다. - Set") @Test - void popMin_less_success() { + void range_less_success() { // Given - zSetRedisRepository.addIfAbsent(key, value + 1, 1); - zSetRedisRepository.addIfAbsent(key, value + 2, 2); - zSetRedisRepository.addIfAbsent(key, value + 3, 3); - zSetRedisRepository.addIfAbsent(key, value + 4, 4); - zSetRedisRepository.addIfAbsent(key, value + 5, 5); + 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.popMin(key, 3); + Set actual = zSetRedisRepository.range(key, 0, 3); // Then assertThat(actual).hasSize(3); - assertThat(valueRedisRepository.hasKey(key)).isTrue(); } } diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 20965ff5..2928f6e7 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -11,6 +11,7 @@ import java.time.LocalDate; import java.util.List; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -300,6 +301,7 @@ void getAllByStatus_Coupon_success(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(1))); } + @Disabled @WithMember @DisplayName("POST - 쿠폰 발급 요청을 성공적으로 한다. - Void") @Test @@ -321,7 +323,7 @@ void registerQueue_success() throws Exception { } @WithMember - @DisplayName("POST - 발급이 가능한 쿠폰이 없는 상황에 쿠폰 발급 요청을 한다. - BadRequestException") + @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not found) - BadRequestException") @Test void registerQueue_Zero_StartAt_BadRequestException() throws Exception { // Given @@ -344,7 +346,7 @@ void registerQueue_Zero_StartAt_BadRequestException() throws Exception { } @WithMember - @DisplayName("POST - 발급 기간이 아닌 쿠폰에 발급 요청을 한다. - BadRequestException") + @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not equals) - BadRequestException") @Test void registerQueue_Not_StartAt_BadRequestException() throws Exception { // Given diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index 871492da..c517b907 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -7,8 +7,6 @@ import java.util.stream.Stream; import org.junit.jupiter.params.provider.Arguments; -import org.springframework.data.redis.core.DefaultTypedTuple; -import org.springframework.data.redis.core.ZSetOperations; import com.moabam.api.domain.coupon.Coupon; import com.moabam.api.domain.coupon.CouponType; @@ -152,19 +150,35 @@ public static Stream provideCoupons() { ); } - public static Stream provideTypedTuples() { - Set> tuples = new HashSet<>(); - tuples.add(new DefaultTypedTuple<>(1L, 1.0)); - tuples.add(new DefaultTypedTuple<>(2L, 2.0)); - tuples.add(new DefaultTypedTuple<>(3L, 3.0)); - tuples.add(new DefaultTypedTuple<>(4L, 4.0)); - tuples.add(new DefaultTypedTuple<>(5L, 5.0)); - tuples.add(new DefaultTypedTuple<>(6L, 6.0)); - tuples.add(new DefaultTypedTuple<>(7L, 7.0)); - tuples.add(new DefaultTypedTuple<>(8L, 8.0)); - tuples.add(new DefaultTypedTuple<>(9L, 9.0)); - tuples.add(new DefaultTypedTuple<>(10L, 10.0)); - - return Stream.of(Arguments.of(tuples)); + 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)); } } From 33fd2a82e36b225d1e5f59783b2e973fa73d4283 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Wed, 29 Nov 2023 15:28:01 +0900 Subject: [PATCH 106/185] =?UTF-8?q?fix:=20Base64=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EB=94=94=EC=BD=94=EB=94=A9=20=EC=BD=94=EB=93=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20->=20Base64Url=20(#173)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Base64관련 디코딩 코드 변경 -> Base64Url * refactor: 쿠폰 스케쥴 업데이트 및 config 수정 * style: 문자열 checkstyle 수정 --- .../auth/JwtAuthenticationService.java | 8 +- src/main/resources/config | 2 +- src/main/resources/static/docs/coupon.html | 2869 +++++++++++++---- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../auth/JwtProviderServiceTest.java | 37 +- 6 files changed, 2314 insertions(+), 606 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java index f43c4aa3..eaf1d456 100644 --- a/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java +++ b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java @@ -1,6 +1,6 @@ package com.moabam.api.application.auth; -import java.util.Base64; +import java.nio.charset.StandardCharsets; import org.json.JSONObject; import org.springframework.stereotype.Service; @@ -13,6 +13,7 @@ import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; import lombok.RequiredArgsConstructor; @Service @@ -37,8 +38,9 @@ public boolean isTokenExpire(String token) { public PublicClaim parseClaim(String token) { String claims = token.split("\\.")[1]; - String decodeClaims = new String(Base64.getDecoder().decode(claims)); - JSONObject jsonObject = new JSONObject(decodeClaims); + 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/resources/config b/src/main/resources/config index 0f4c27e5..6320f486 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 0f4c27e5bb593650ad4733c98f5c27b54eebc3c2 +Subproject commit 6320f4860f0be17c32492602f152b6d5a2f9f508 diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 0b834827..d2b00d0d 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,467 +1,2138 @@ - - - - -쿠폰(Coupon) - - + + + + + 쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 175
+Content-Length: 183
 Host: localhost:8080
 
 {
@@ -473,66 +2144,66 @@ 

요청

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 201 Created
 Access-Control-Allow-Origin:
 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와 일치하는 쿠폰을 삭제합니다.
-
-
-

요청

-
-
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
DELETE /admins/coupons/34 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 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와 일치하는 쿠폰을 조회합니다.
-
-
-
-

요청

-
-
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -540,7 +2211,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 198 +Content-Length: 208 { "id" : 22, @@ -553,36 +2224,36 @@

응답

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -590,7 +2261,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 199 +Content-Length: 209 [ { "id" : 23, @@ -603,33 +2274,33 @@

응답

"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 400 Bad Request
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -637,34 +2308,34 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 64 +Content-Length: 66 { "message" : "쿠폰 발급 가능 기간이 아닙니다." }
-
-
-
-
-
-
-

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

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

요청

-
-
+
+
+
+
+
+
+

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

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -672,7 +2343,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 472 +Content-Length: 502 [ { "id" : 17, @@ -705,49 +2376,49 @@

응답

"point" : 10, "type" : "MORNING" } ]
-
-
-
-
-
-
-

쿠폰을 사용

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

요청

-
-
+
+
+
+
+
+
+

쿠폰을 사용

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

요청

+
+
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:
 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 index 62f80fd6..2f33f008 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index d56f528e..6f789844 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java index e9190c28..9c985f57 100644 --- a/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java @@ -9,14 +9,16 @@ 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.application.auth.JwtProviderService; 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 { @@ -56,6 +58,39 @@ void create_access_token_success() throws JSONException { 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 { From 11aca2e2789303ba34e857a35961a50ad8a01e41 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 16:12:15 +0900 Subject: [PATCH 107/185] =?UTF-8?q?hotfix:=20sql=20init=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 2 +- docker-compose-dev.yml | 1 + {src/main/resources/sql => mysql/initdb.d}/data.sql | 0 {src/main/resources/sql => mysql/initdb.d}/schema.sql | 0 src/main/resources/config | 2 +- 5 files changed, 3 insertions(+), 2 deletions(-) rename {src/main/resources/sql => mysql/initdb.d}/data.sql (100%) rename {src/main/resources/sql => mysql/initdb.d}/schema.sql (100%) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 60bad952..f7216dc6 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -53,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_INSTANCE_USERNAME }} key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, ./scripts/*, ./nginx/*" + source: "./.env, ./docker-compose-dev.yml, ./scripts/*, ./nginx/*, ./mysql/*" target: "/home/ubuntu/moabam" - name: 파일 세팅 diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 4d1e85a0..4ae64366 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -72,3 +72,4 @@ services: - --collation-server=utf8mb4_unicode_ci volumes: - /home/ubuntu/moabam/data/mysql:/var/lib/mysql + - /home/ubuntu/moabam/sql/initdb.d:/docker-entrypoint-initdb.d diff --git a/src/main/resources/sql/data.sql b/mysql/initdb.d/data.sql similarity index 100% rename from src/main/resources/sql/data.sql rename to mysql/initdb.d/data.sql diff --git a/src/main/resources/sql/schema.sql b/mysql/initdb.d/schema.sql similarity index 100% rename from src/main/resources/sql/schema.sql rename to mysql/initdb.d/schema.sql diff --git a/src/main/resources/config b/src/main/resources/config index 6320f486..a13c9f42 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 6320f4860f0be17c32492602f152b6d5a2f9f508 +Subproject commit a13c9f42f0dad81be5abd9fde50afa3e905e3509 From b19243e26acc7912feb3dfa11369754687742065 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 16:29:42 +0900 Subject: [PATCH 108/185] hotfix: docker-compose mysql --- .github/workflows/develop-cd.yml | 2 ++ docker-compose-dev.yml | 3 ++- mysql/initdb.d/{schema.sql => init.sql} | 0 mysql/initdb.d/{data.sql => item-data.sql} | 0 src/main/resources/config | 2 +- 5 files changed, 5 insertions(+), 2 deletions(-) rename mysql/initdb.d/{schema.sql => init.sql} (100%) rename mysql/initdb.d/{data.sql => item-data.sql} (100%) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index f7216dc6..0542aaab 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -69,6 +69,8 @@ jobs: chmod +x ./scripts/deploy-dev.sh chmod +x ./scripts/init-letsencrypt.sh chmod +x ./scripts/init-nginx-converter.sh + chmod +x ./mysql/init.sql + chmod +x ./mysql/item-data.sql - name: Github Actions IP 보안그룹에서 삭제 if: always() diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 4ae64366..5d953072 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -70,6 +70,7 @@ services: command: - --character-set-server=utf8mb4 - --collation-server=utf8mb4_unicode_ci + - --skip-character-set-client-handshake volumes: - /home/ubuntu/moabam/data/mysql:/var/lib/mysql - - /home/ubuntu/moabam/sql/initdb.d:/docker-entrypoint-initdb.d + - /home/ubuntu/moabam/mysql/initdb.d:/docker-entrypoint-initdb.d diff --git a/mysql/initdb.d/schema.sql b/mysql/initdb.d/init.sql similarity index 100% rename from mysql/initdb.d/schema.sql rename to mysql/initdb.d/init.sql diff --git a/mysql/initdb.d/data.sql b/mysql/initdb.d/item-data.sql similarity index 100% rename from mysql/initdb.d/data.sql rename to mysql/initdb.d/item-data.sql diff --git a/src/main/resources/config b/src/main/resources/config index a13c9f42..4dbde487 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit a13c9f42f0dad81be5abd9fde50afa3e905e3509 +Subproject commit 4dbde487226312c2a144165aa28c0cc0b1e7a0bf From ed724f04d0eae4377b5b8df6867dca1fb145c8b5 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 16:31:35 +0900 Subject: [PATCH 109/185] hotfix: docker-compose mysql --- .github/workflows/develop-cd.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 0542aaab..bca6e32e 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -69,8 +69,8 @@ jobs: chmod +x ./scripts/deploy-dev.sh chmod +x ./scripts/init-letsencrypt.sh chmod +x ./scripts/init-nginx-converter.sh - chmod +x ./mysql/init.sql - chmod +x ./mysql/item-data.sql + chmod +x ./mysql/initdb.d/init.sql + chmod +x ./mysql/initdb.d/item-data.sql - name: Github Actions IP 보안그룹에서 삭제 if: always() From bbf397411598ba6b777f96b2a522845c9f55821d Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Wed, 29 Nov 2023 17:13:29 +0900 Subject: [PATCH 110/185] =?UTF-8?q?fix:=20=EB=B0=A9=EC=9E=A5=20=EC=9E=90?= =?UTF-8?q?=EC=8B=A0=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=B6=94=EB=B0=A9=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20(#177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 방장 자신 추방 못하도록 validate 추가 * feature: 방 수정 전 정보 불러오기에 방장 ID 추가 * test: 테스트 코드 작성 * fix: 방 참여 기록 조회 최신순으로 변경 --- .../api/application/room/RoomService.java | 7 +++++++ .../api/application/room/SearchService.java | 2 +- .../application/room/mapper/RoomMapper.java | 3 ++- .../ParticipantSearchRepository.java | 1 + .../api/dto/room/ManageRoomResponse.java | 1 + .../global/error/model/ErrorMessage.java | 1 + .../api/presentation/RoomControllerTest.java | 19 +++++++++++++++++++ 7 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index fbd1fc4e..63b2490c 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -135,6 +135,7 @@ public void mandateManager(Long managerId, Long roomId, Long memberId) { @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); @@ -172,6 +173,12 @@ private Participant getParticipant(Long memberId, Long 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); diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index fafe30d0..5aec7dc1 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -147,7 +147,7 @@ public ManageRoomResponse getRoomForModification(Long memberId, Long roomId) { participantResponses.add(ParticipantMapper.toParticipantResponse(member, contributionPoint)); } - return RoomMapper.toManageRoomResponse(room, routineResponses, participantResponses); + return RoomMapper.toManageRoomResponse(room, memberId, routineResponses, participantResponses); } public GetAllRoomsResponse getAllRooms(@Nullable RoomType roomType, @Nullable Long roomId) { 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 index f80eec07..8282c0ee 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -99,11 +99,12 @@ public static RoomsHistoryResponse toRoomsHistoryResponse(List routines, + 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()) 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 index c99a6d48..761ea8c3 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -62,6 +62,7 @@ public List findAllParticipantsByMemberId(Long memberId) { .where( participant.memberId.eq(memberId) ) + .orderBy(participant.createdAt.desc()) .fetch(); } diff --git a/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java b/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java index 98f495ae..cbf9c261 100644 --- a/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java +++ b/src/main/java/com/moabam/api/dto/room/ManageRoomResponse.java @@ -10,6 +10,7 @@ public record ManageRoomResponse( Long roomId, String title, + Long managerId, String announcement, RoomType roomType, int certifyTime, diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 408e5574..907c756b 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -29,6 +29,7 @@ public enum ErrorMessage { INVALID_REQUEST_URL("잘못된 URL 요청입니다."), INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), CERTIFICATION_NOT_FOUND("인증 정보가 없습니다."), + PARTICIPANT_DEPORT_ERROR("방장은 자신을 추방할 수 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 265450c4..0577f001 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -887,6 +887,25 @@ void deport_member_success() throws Exception { 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 From 9c086d103eab741be59268851477d00fe8ac336c Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Wed, 29 Nov 2023 18:48:22 +0900 Subject: [PATCH 111/185] Fix/#175 fix member delete error (#178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Base64관련 디코딩 코드 변경 -> Base64Url * refactor: 쿠폰 스케쥴 업데이트 및 config 수정 * style: 문자열 checkstyle 수정 * fix: 회원 탈퇴시 방 참여에 대한 문제 해결 * refactor: config update * test: 신고 실패에 대한 테스트 코드 변경 --- .../api/application/member/MemberService.java | 28 ++++++++++++++++++- .../api/application/report/ReportService.java | 19 +++++++------ .../api/application/room/RoomService.java | 4 +-- .../moabam/api/domain/member/BadgeType.java | 2 +- .../com/moabam/api/domain/member/Member.java | 12 ++++++-- .../repository/ParticipantRepository.java | 3 ++ .../ParticipantSearchRepository.java | 11 ++++++++ .../moabam/api/dto/member/BadgeResponse.java | 2 +- .../api/presentation/RoomController.java | 2 +- .../global/error/model/ErrorMessage.java | 1 + .../application/member/MemberServiceTest.java | 10 +++++++ .../application/report/ReportServiceTest.java | 2 +- .../api/application/room/RoomServiceTest.java | 4 +-- .../presentation/MemberControllerTest.java | 26 ++++++++--------- .../presentation/ReportControllerTest.java | 9 ++++-- 15 files changed, 98 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 295a6186..2c53f243 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -17,6 +17,9 @@ 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; @@ -42,6 +45,8 @@ public class MemberService { 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) { @@ -69,6 +74,12 @@ public Member findMemberToDelete(Long memberId) { @Transactional public void delete(Member member) { + List participants = participantRepository.findAllByMemberId(member.getId()); + + if (!participants.isEmpty()) { + throw new BadRequestException(NEED_TO_EXIT_ALL_ROOMS); + } + member.delete(clockHolder.times()); memberRepository.flush(); memberRepository.delete(member); @@ -92,14 +103,29 @@ public void modifyInfo(AuthMember authMember, ModifyMemberRequest modifyMemberRe Member member = memberSearchRepository.findMember(authMember.id()) .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); - member.changeNickName(modifyMemberRequest.nickname()); + boolean nickNameChanged = member.changeNickName(modifyMemberRequest.nickname()); member.changeIntro(modifyMemberRequest.intro()); member.changeProfileUri(newProfileUri); memberRepository.save(member); + + if (nickNameChanged) { + changeNickname(authMember.id(), modifyMemberRequest.nickname()); + } + } + + 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); } diff --git a/src/main/java/com/moabam/api/application/report/ReportService.java b/src/main/java/com/moabam/api/application/report/ReportService.java index f626383d..5735c63a 100644 --- a/src/main/java/com/moabam/api/application/report/ReportService.java +++ b/src/main/java/com/moabam/api/application/report/ReportService.java @@ -31,7 +31,7 @@ public class ReportService { @Transactional public void report(AuthMember authMember, ReportRequest reportRequest) { - validateNoReportSubject(reportRequest.roomId(), reportRequest.certificationId()); + validateNoReportSubject(reportRequest.reportedId()); Report report = createReport(authMember.id(), reportRequest); reportRepository.save(report); } @@ -39,21 +39,22 @@ public void report(AuthMember authMember, ReportRequest reportRequest) { private Report createReport(Long reporterId, ReportRequest reportRequest) { Member reportedMember = memberService.findMember(reportRequest.reportedId()); + Certification certification = null; if (nonNull(reportRequest.certificationId())) { - Certification certification = certificationService.findCertification(reportRequest.certificationId()); - - return ReportMapper.toReport(reporterId, reportedMember.getId(), - null, certification, reportRequest.description()); + certification = certificationService.findCertification(reportRequest.certificationId()); } - Room room = roomService.findRoom(reportRequest.roomId()); + Room room = null; + if (nonNull(reportRequest.roomId())) { + room = roomService.findRoom(reportRequest.roomId()); + } return ReportMapper.toReport(reporterId, reportedMember.getId(), - room, null, reportRequest.description()); + room, certification, reportRequest.description()); } - private void validateNoReportSubject(Long roomId, Long certificationId) { - if (isNull(roomId) && isNull(certificationId)) { + 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/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 63b2490c..d14228a6 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -45,7 +45,7 @@ public class RoomService { private final MemberService memberService; @Transactional - public Long createRoom(Long memberId, String nickname, CreateRoomRequest createRoomRequest) { + 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); @@ -55,7 +55,7 @@ public Long createRoom(Long memberId, String nickname, CreateRoomRequest createR Member member = memberService.findMember(memberId); member.enterRoom(room.getRoomType()); participant.enableManager(); - room.changeManagerNickname(nickname); + room.changeManagerNickname(member.getNickname()); Room savedRoom = roomRepository.save(room); routineRepository.saveAll(routines); diff --git a/src/main/java/com/moabam/api/domain/member/BadgeType.java b/src/main/java/com/moabam/api/domain/member/BadgeType.java index 92e90edf..82a7ebd1 100644 --- a/src/main/java/com/moabam/api/domain/member/BadgeType.java +++ b/src/main/java/com/moabam/api/domain/member/BadgeType.java @@ -27,7 +27,7 @@ public enum BadgeType { public static List memberBadgeMap(Set badgeTypes) { return Arrays.stream(BadgeType.values()) .map(badgeType -> BadgeResponse.builder() - .badge(badgeType) + .badge(badgeType.korean) .unlock(badgeTypes.contains(badgeType)) .build()) .toList(); diff --git a/src/main/java/com/moabam/api/domain/member/Member.java b/src/main/java/com/moabam/api/domain/member/Member.java index 48486941..51e8d9c3 100644 --- a/src/main/java/com/moabam/api/domain/member/Member.java +++ b/src/main/java/com/moabam/api/domain/member/Member.java @@ -7,6 +7,7 @@ import static java.util.Objects.*; import java.time.LocalDateTime; +import java.util.Objects; import org.hibernate.annotations.ColumnDefault; import org.hibernate.annotations.SQLDelete; @@ -47,7 +48,7 @@ public class Member extends BaseTimeEntity { @Column(name = "social_id", nullable = false, unique = true) private String socialId; - @Column(name = "nickname", nullable = false, unique = true) + @Column(name = "nickname", unique = true) private String nickname; @Column(name = "intro", length = 30) @@ -134,10 +135,15 @@ public void increaseTotalCertifyCount() { public void delete(LocalDateTime now) { socialId = deleteSocialId(now); + nickname = null; } - public void changeNickName(String nickname) { - this.nickname = requireNonNullElse(nickname, this.nickname); + public boolean changeNickName(String nickname) { + if (Objects.isNull(nickname)) { + return false; + } + this.nickname = nickname; + return true; } public void changeIntro(String intro) { 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 index 6d79e3df..875e6a03 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantRepository.java @@ -1,9 +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 index 761ea8c3..ed9ea08e 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -76,4 +76,15 @@ public List findAllByRoomCertifyTime(int certifyTime) { ) .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/dto/member/BadgeResponse.java b/src/main/java/com/moabam/api/dto/member/BadgeResponse.java index 7e83d5d6..a00158ba 100644 --- a/src/main/java/com/moabam/api/dto/member/BadgeResponse.java +++ b/src/main/java/com/moabam/api/dto/member/BadgeResponse.java @@ -6,7 +6,7 @@ @Builder public record BadgeResponse( - BadgeType badge, + String badge, boolean unlock ) { diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index ce4725e3..2581e5e1 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -52,7 +52,7 @@ public class RoomController { @PostMapping @ResponseStatus(HttpStatus.CREATED) public Long createRoom(@Auth AuthMember authMember, @Valid @RequestBody CreateRoomRequest createRoomRequest) { - return roomService.createRoom(authMember.id(), authMember.nickname(), createRoomRequest); + return roomService.createRoom(authMember.id(), createRoomRequest); } @GetMapping diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 907c756b..8a16d99b 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -29,6 +29,7 @@ public enum ErrorMessage { INVALID_REQUEST_URL("잘못된 URL 요청입니다."), INVALID_CERTIFY_TIME("현재 인증 시간이 아닙니다."), CERTIFICATION_NOT_FOUND("인증 정보가 없습니다."), + NEED_TO_EXIT_ALL_ROOMS("모든 방에서 나가야 회원 탈퇴가 가능합니다."), PARTICIPANT_DEPORT_ERROR("방장은 자신을 추방할 수 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index a7959641..c4a4d6cc 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -24,6 +24,8 @@ 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; @@ -54,6 +56,12 @@ class MemberServiceTest { @Mock MemberSearchRepository memberSearchRepository; + @Mock + ParticipantRepository participantRepository; + + @Mock + ParticipantSearchRepository participantSearchRepository; + @Mock InventorySearchRepository inventorySearchRepository; @@ -204,6 +212,8 @@ void modify_success_test(@WithMember AuthMember authMember) { 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"); diff --git a/src/test/java/com/moabam/api/application/report/ReportServiceTest.java b/src/test/java/com/moabam/api/application/report/ReportServiceTest.java index 25b56d96..4adc8c01 100644 --- a/src/test/java/com/moabam/api/application/report/ReportServiceTest.java +++ b/src/test/java/com/moabam/api/application/report/ReportServiceTest.java @@ -52,7 +52,7 @@ class ReportServiceTest { @Test void no_report_subject_fail(@WithMember AuthMember authMember) { // given - ReportRequest reportRequest = new ReportRequest(5L, null, null, "st"); + ReportRequest reportRequest = new ReportRequest(null, null, null, "st"); // When + Then assertThatThrownBy(() -> reportService.report(authMember, reportRequest)) diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java index c2921883..667508f0 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -70,7 +70,7 @@ void create_room_no_password_success() { given(memberService.findMember(1L)).willReturn(member); // when - Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); + Long result = roomService.createRoom(1L, createRoomRequest); // then verify(roomRepository).save(any(Room.class)); @@ -98,7 +98,7 @@ void create_room_with_password_success() { given(memberService.findMember(1L)).willReturn(member); // when - Long result = roomService.createRoom(1L, "닉네임", createRoomRequest); + Long result = roomService.createRoom(1L, createRoomRequest); // then verify(roomRepository).save(any(Room.class)); diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 4c19555c..659bc5a8 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -192,7 +192,7 @@ void delete_member_success() throws Exception { Member deletedMEmber = deletedMemberOptional.get(); assertThat(deletedMEmber.getDeletedAt()).isNotNull(); - assertThat(deletedMEmber.getNickname()).isEqualTo(nickname); + assertThat(deletedMEmber.getNickname()).isNull(); } @DisplayName("회원이 없어서 회원 삭제 실패") @@ -291,13 +291,13 @@ void search_my_info_success() throws Exception { // MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getImage()), // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), - MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("오목눈이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(false), MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), @@ -342,13 +342,13 @@ void search_my_info_with_no_badge_success() throws Exception { // MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getImage()), // MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getImage()), - MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("오목눈이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(false), MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), @@ -405,13 +405,13 @@ void search_friend_info_success() throws Exception { MockMvcResultMatchers.jsonPath("$.birds.MORNING").value(morningInven.getItem().getAwakeImage()), MockMvcResultMatchers.jsonPath("$.birds.NIGHT").value(nightInven.getItem().getAwakeImage()), - MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("MORNING_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[0].badge").value("오목눈이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("MORNING_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("NIGHT_BIRTH"), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("NIGHT_ADULT"), + MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(true) ).andDo(print()); } diff --git a/src/test/java/com/moabam/api/presentation/ReportControllerTest.java b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java index fd724c07..006eb10a 100644 --- a/src/test/java/com/moabam/api/presentation/ReportControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java @@ -105,19 +105,22 @@ void reports_success(boolean roomFilter, boolean certificationFilter) throws Exc .andExpect(status().is2xxSuccessful()); } - @DisplayName("방과 인증 값 둘 다 들어오지 않는다면 테스트 실패") + @DisplayName("사용자 신고 성공") @WithMember @Test void reports_failBy_subject_null() throws Exception { // given - ReportRequest reportRequest = ReportFixture.reportRequest(123L, null, null); + Member member = MemberFixture.member("2", "ji"); + 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().isBadRequest()); + .andExpect(status().is2xxSuccessful()); } @DisplayName("회원 조회 실패로 신고 실패") From 6a6ced12dba02a5a1e5013c860b257b7a3bb2779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Wed, 29 Nov 2023 21:31:06 +0900 Subject: [PATCH 112/185] =?UTF-8?q?refactor:=20=EC=BF=A0=ED=8F=B0,=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=EC=BD=94=EB=93=9C=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?(#180)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: coupon 발행 및 삭제 스타일 변경 * refactor: My Coupon 조회 코드 개선 * refactor: 쿠폰 등록, 사용 코드 개선 * refactor: FCM 및 알림 코드 개선 --- .../coupon/CouponManageService.java | 2 +- .../api/application/coupon/CouponMapper.java | 1 + .../api/application/coupon/CouponService.java | 55 +- .../notification/NotificationService.java | 15 +- .../application/payment/PaymentService.java | 2 +- .../CouponWalletSearchRepository.java | 8 +- .../repository/NotificationRepository.java | 20 +- .../api/dto/coupon/MyCouponResponse.java | 1 + .../api/infrastructure/fcm/FcmRepository.java | 13 +- .../api/infrastructure/fcm/FcmService.java | 7 +- .../api/presentation/CouponController.java | 30 +- .../presentation/NotificationController.java | 13 +- src/main/resources/static/docs/coupon.html | 2909 ++++------------- src/main/resources/static/docs/index.html | 2 +- .../resources/static/docs/notification.html | 2 +- .../coupon/CouponManageServiceTest.java | 4 +- .../application/coupon/CouponServiceTest.java | 82 +- .../notification/NotificationServiceTest.java | 20 +- .../payment/PaymentServiceTest.java | 2 +- .../CouponWalletSearchRepositoryTest.java | 19 +- .../infrastructure/fcm/FcmRepositoryTest.java | 26 +- .../infrastructure/fcm/FcmServiceTest.java | 27 +- .../presentation/CouponControllerTest.java | 193 +- .../NotificationControllerTest.java | 4 +- .../support/snippet/CouponWalletSnippet.java | 1 + 25 files changed, 849 insertions(+), 2609 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index 4d46171a..09be5dc1 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -73,7 +73,7 @@ public void issue() { } } - public void registerQueue(Long memberId, String couponName) { + public void registerQueue(String couponName, Long memberId) { double registerTime = System.currentTimeMillis(); validateRegisterQueue(couponName); couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index ef8eb15a..d8799ab1 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -52,6 +52,7 @@ 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()) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index a25af06e..43b289fb 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -19,7 +19,6 @@ 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.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.BadRequestException; import com.moabam.global.error.exception.ConflictException; @@ -42,29 +41,47 @@ public class CouponService { private final CouponWalletSearchRepository couponWalletSearchRepository; @Transactional - public void create(AuthMember admin, CreateCouponRequest request) { - validateAdminRole(admin); + 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(admin.id(), request); + Coupon coupon = CouponMapper.toEntity(adminId, request); + couponRepository.save(coupon); } @Transactional - public void use(Long memberId, Long couponWalletId) { - CouponWallet couponWallet = getWalletByIdAndMemberId(couponWalletId, memberId); + 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.deleteQueue(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 memberId, Long couponWalletId) { - CouponWallet couponWallet = getWalletByIdAndMemberId(couponWalletId, memberId); + 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()) { @@ -74,15 +91,6 @@ public void discount(Long memberId, Long couponWalletId) { couponWalletRepository.delete(couponWallet); } - @Transactional - public void delete(AuthMember admin, Long couponId) { - validateAdminRole(admin); - Coupon coupon = couponRepository.findById(couponId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); - couponRepository.delete(coupon); - couponManageService.deleteQueue(coupon.getName()); - } - public CouponResponse getById(Long couponId) { Coupon coupon = couponRepository.findById(couponId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); @@ -97,18 +105,13 @@ public List getAllByStatus(CouponStatusRequest request) { return CouponMapper.toResponses(coupons); } - public List getWallet(Long couponId, AuthMember authMember) { + public List getAllByWalletIdAndMemberId(Long couponWalletId, Long memberId) { List couponWallets = - couponWalletSearchRepository.findAllByCouponIdAndMemberId(couponId, authMember.id()); + couponWalletSearchRepository.findAllByIdAndMemberId(couponWalletId, memberId); return CouponMapper.toMyResponses(couponWallets); } - public CouponWallet getWalletByIdAndMemberId(Long couponWalletId, Long memberId) { - return couponWalletSearchRepository.findByIdAndMemberId(couponWalletId, memberId) - .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON_WALLET)); - } - private void validatePeriod(LocalDate startAt, LocalDate openAt) { LocalDate now = clockHolder.date(); @@ -121,8 +124,8 @@ private void validatePeriod(LocalDate startAt, LocalDate openAt) { } } - private void validateAdminRole(AuthMember admin) { - if (!admin.role().equals(Role.ADMIN)) { + private void validateAdminRole(Role role) { + if (!role.equals(Role.ADMIN)) { throw new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND); } } diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 7eedd953..3296fce5 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -16,7 +16,6 @@ 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.auth.model.AuthMember; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.exception.ConflictException; import com.moabam.global.error.exception.NotFoundException; @@ -40,14 +39,14 @@ public class NotificationService { private final ParticipantSearchRepository participantSearchRepository; @Transactional - public void sendKnock(AuthMember member, Long targetId, Long roomId) { + public void sendKnock(Long roomId, Long targetId, Long memberId, String memberNickname) { roomService.validateRoomById(roomId); - validateConflictKnock(member.id(), targetId, roomId); + validateConflictKnock(roomId, targetId, memberId); String fcmToken = fcmService.findTokenByMemberId(targetId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); - fcmService.sendAsync(fcmToken, String.format(KNOCK_BODY, member.nickname())); - notificationRepository.saveKnock(member.id(), targetId, roomId); + fcmService.sendAsync(fcmToken, String.format(KNOCK_BODY, memberNickname)); + notificationRepository.saveKnock(roomId, targetId, memberId); } public void sendCouponIssueResult(Long memberId, String couponName, String body) { @@ -74,7 +73,7 @@ public List getMyKnockStatusInRoom(Long memberId, Long roomId, List knockPredicate = targetId -> - notificationRepository.existsKnockByKey(memberId, targetId, roomId); + notificationRepository.existsKnockByKey(roomId, targetId, memberId); Map> knockStatus = filteredParticipants.stream() .map(Participant::getMemberId) @@ -83,8 +82,8 @@ public List getMyKnockStatusInRoom(Long memberId, Long roomId, List findAllByCouponIdAndMemberId(Long couponId, Long memberId) { + public List findAllByIdAndMemberId(Long couponWalletId, Long memberId) { return jpaQueryFactory .selectFrom(couponWallet) .join(couponWallet.coupon, coupon).fetchJoin() .where( - DynamicQuery.generateEq(couponId, couponWallet.coupon.id::eq), + DynamicQuery.generateEq(couponWalletId, couponWallet.id::eq), DynamicQuery.generateEq(memberId, couponWallet.memberId::eq) ) .fetch(); } - public Optional findByIdAndMemberId(Long id, Long memberId) { + public Optional findByIdAndMemberId(Long couponWalletId, Long memberId) { return Optional.ofNullable(jpaQueryFactory .selectFrom(couponWallet) .join(couponWallet.coupon, coupon).fetchJoin() .where( - couponWallet.id.eq(id), + couponWallet.id.eq(couponWalletId), couponWallet.memberId.eq(memberId)) .fetchOne() ); 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 index 30cdd5d3..ce856cc8 100644 --- a/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java +++ b/src/main/java/com/moabam/api/domain/notification/repository/NotificationRepository.java @@ -15,21 +15,27 @@ @RequiredArgsConstructor public class NotificationRepository { - private static final String KNOCK_KEY = "room_%s_member_%s_knocks_%s"; + 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 memberId, Long targetId, Long roomId) { - String knockKey = - String.format(KNOCK_KEY, requireNonNull(roomId), requireNonNull(memberId), requireNonNull(targetId)); + 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 memberId, Long targetId, Long roomId) { - String knockKey = - String.format(KNOCK_KEY, requireNonNull(roomId), requireNonNull(memberId), requireNonNull(targetId)); + 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/dto/coupon/MyCouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java index a0201ebb..60859b7a 100644 --- a/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java +++ b/src/main/java/com/moabam/api/dto/coupon/MyCouponResponse.java @@ -6,6 +6,7 @@ @Builder public record MyCouponResponse( + Long walletId, Long id, String name, String description, diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java index 39b97666..2d57c6b5 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmRepository.java @@ -18,12 +18,13 @@ public class FcmRepository { private final ValueRedisRepository valueRedisRepository; - public void saveToken(Long memberId, String fcmToken) { + public void saveToken(String fcmToken, Long memberId) { + String tokenKey = String.valueOf(requireNonNull(memberId)); + valueRedisRepository.save( - String.valueOf(requireNonNull(memberId)), + tokenKey, requireNonNull(fcmToken), - Duration.ofDays(EXPIRE_FCM_TOKEN) - ); + Duration.ofDays(EXPIRE_FCM_TOKEN)); } public void deleteTokenByMemberId(Long memberId) { @@ -33,8 +34,4 @@ public void deleteTokenByMemberId(Long memberId) { public String findTokenByMemberId(Long memberId) { return valueRedisRepository.get(String.valueOf(requireNonNull(memberId))); } - - public boolean existsTokenByMemberId(Long memberId) { - return valueRedisRepository.hasKey(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 index 1d8dd25b..cc575968 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -7,7 +7,6 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; -import com.moabam.global.auth.model.AuthMember; import lombok.RequiredArgsConstructor; @@ -18,16 +17,14 @@ public class FcmService { private final FirebaseMessaging firebaseMessaging; private final FcmRepository fcmRepository; - // TODO : 세연님 로그인 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 저장하면 됩니다. Front와 상의 후 삭제예정 - public void createToken(AuthMember authMember, String fcmToken) { + public void createToken(String fcmToken, Long memberId) { if (fcmToken == null || fcmToken.isBlank()) { return; } - fcmRepository.saveToken(authMember.id(), fcmToken); + fcmRepository.saveToken(fcmToken, memberId); } - // TODO : 세연님 로그아웃 시, 해당 메서드 사용해서 해당 유저의 FCM TOKEN 삭제하시면 됩니다. (이 코드는 원하시면 변경하셔도 됩니다.) public void deleteTokenByMemberId(Long memberId) { fcmRepository.deleteTokenByMemberId(memberId); } diff --git a/src/main/java/com/moabam/api/presentation/CouponController.java b/src/main/java/com/moabam/api/presentation/CouponController.java index 4668e431..01931878 100644 --- a/src/main/java/com/moabam/api/presentation/CouponController.java +++ b/src/main/java/com/moabam/api/presentation/CouponController.java @@ -33,14 +33,14 @@ public class CouponController { @PostMapping("/admins/coupons") @ResponseStatus(HttpStatus.CREATED) - public void create(@Auth AuthMember admin, @Valid @RequestBody CreateCouponRequest request) { - couponService.create(admin, request); + 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(@Auth AuthMember admin, @PathVariable("couponId") Long couponId) { - couponService.delete(admin, couponId); + public void delete(@PathVariable("couponId") Long couponId, @Auth AuthMember admin) { + couponService.delete(couponId, admin.role()); } @GetMapping("/coupons/{couponId}") @@ -55,22 +55,24 @@ public List getAllByStatus(@Valid @RequestBody CouponStatusReque return couponService.getAllByStatus(request); } - @PostMapping("/coupons") + @GetMapping({"/my-coupons", "/my-coupons/{couponWalletId}"}) @ResponseStatus(HttpStatus.OK) - public void registerQueue(@Auth AuthMember authMember, @RequestParam("couponName") String couponName) { - couponManageService.registerQueue(authMember.id(), couponName); + public List getAllByWalletIdAndMemberId( + @PathVariable(value = "couponWalletId", required = false) Long couponWalletId, + @Auth AuthMember authMember + ) { + return couponService.getAllByWalletIdAndMemberId(couponWalletId, authMember.id()); } - @GetMapping({"/my-coupons", "/my-coupons/{couponId}"}) + @PostMapping("/my-coupons/{couponWalletId}") @ResponseStatus(HttpStatus.OK) - public List getWallet(@Auth AuthMember authMember, - @PathVariable(value = "couponId", required = false) Long couponId) { - return couponService.getWallet(couponId, authMember); + public void use(@PathVariable("couponWalletId") Long couponWalletId, @Auth AuthMember authMember) { + couponService.use(couponWalletId, authMember.id()); } - @PostMapping("/my-coupons/{couponWalletId}") + @PostMapping("/coupons") @ResponseStatus(HttpStatus.OK) - public void use(@Auth AuthMember authMember, @PathVariable("couponWalletId") Long couponWalletId) { - couponService.use(authMember.id(), couponWalletId); + 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/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java index ef6400a5..6b5287d9 100644 --- a/src/main/java/com/moabam/api/presentation/NotificationController.java +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -26,14 +26,17 @@ public class NotificationController { @GetMapping("/rooms/{roomId}/members/{memberId}") @ResponseStatus(HttpStatus.OK) - public void sendKnockNotification(@Auth AuthMember member, @PathVariable("roomId") Long roomId, - @PathVariable("memberId") Long memberId) { - notificationService.sendKnock(member, memberId, roomId); + public void sendKnock( + @PathVariable("roomId") Long roomId, + @PathVariable("memberId") Long memberId, + @Auth AuthMember authMember + ) { + notificationService.sendKnock(roomId, memberId, authMember.id(), authMember.nickname()); } @PostMapping @ResponseStatus(HttpStatus.OK) - public void createFcmToken(@Auth AuthMember authMember, @RequestParam("fcmToken") String fcmToken) { - fcmService.createToken(authMember, fcmToken); + public void createFcmToken(@RequestParam("fcmToken") String fcmToken, @Auth AuthMember authMember) { + fcmService.createToken(fcmToken, authMember.id()); } } diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index d2b00d0d..14237f4b 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -1,2138 +1,467 @@ - - - - - 쿠폰(Coupon) - - + + + + +쿠폰(Coupon) + +
-
-

쿠폰(Coupon)

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

쿠폰 생성

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

요청

-
-
+
+

쿠폰(Coupon)

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

쿠폰 생성

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

요청

+
+
POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 183
+Content-Length: 175
 Host: localhost:8080
 
 {
@@ -2144,66 +473,66 @@ 

요청

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 201 Created
 Access-Control-Allow-Origin:
 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/34 HTTP/1.1
+
+
+
+
+
+

쿠폰 삭제

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

요청

+
+
+
DELETE /admins/coupons/35 HTTP/1.1
 Host: localhost:8080
-
-
-

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 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/22 HTTP/1.1
+
+
+
+
+
+

특정 쿠폰 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -2211,10 +540,10 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 208 +Content-Length: 198 { - "id" : 22, + "id" : 23, "adminName" : "1admin", "name" : "couponName", "description" : "", @@ -2224,36 +553,36 @@

응답

"startAt" : "2023-02-01", "openAt" : "2023-01-01" }
-
-
-
-
-
-
-

상태에 따른 쿠폰들을 조회

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

요청

-
-
+
+
+
+
+
+
+

상태에 따른 쿠폰들을 조회

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -2261,10 +590,10 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 209 +Content-Length: 199 [ { - "id" : 23, + "id" : 24, "adminName" : "1admin", "name" : "coupon1", "description" : "", @@ -2274,33 +603,33 @@

응답

"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 400 Bad Request
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -2308,34 +637,34 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 66 +Content-Length: 64 { "message" : "쿠폰 발급 가능 기간이 아닙니다." }
-
-
-
-
-
-
-

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

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

요청

-
-
+
+
+
+
+
+
+

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

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

요청

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

응답

-
-
+
+
+

응답

+
+
HTTP/1.1 200 OK
 Access-Control-Allow-Origin:
 Access-Control-Allow-Methods: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
@@ -2343,82 +672,52 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 502 +Content-Length: 3 -[ { - "id" : 17, - "name" : "c1", - "description" : "", - "point" : 10, - "type" : "MORNING" -}, { - "id" : 18, - "name" : "c2", - "description" : "", - "point" : 10, - "type" : "MORNING" -}, { - "id" : 19, - "name" : "c3", - "description" : "", - "point" : 10, - "type" : "MORNING" -}, { - "id" : 20, - "name" : "c4", - "description" : "", - "point" : 10, - "type" : "MORNING" -}, { - "id" : 21, - "name" : "c5", - "description" : "", - "point" : 10, - "type" : "MORNING" -} ]
-
-
-
-
-
-
-

쿠폰을 사용

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

요청

-
-
+[ ] +
+
+
+
+
+
+

쿠폰을 사용

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

요청

+
+
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:
 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 index 2f33f008..62f80fd6 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index 6f789844..d56f528e 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index bc0de6cf..ea6a7b48 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -129,7 +129,7 @@ void registerQueue_success() { given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(true); // When - couponManageService.registerQueue(1L, coupon.getName()); + couponManageService.registerQueue(coupon.getName(), 1L); // Then verify(couponManageRepository).addIfAbsentQueue(any(String.class), any(Long.class), any(double.class)); @@ -143,7 +143,7 @@ void registerQueue_No_BadRequestException() { given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> couponManageService.registerQueue(1L, "couponName")) + assertThatThrownBy(() -> couponManageService.registerQueue("couponName", 1L)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 6e1efbe9..449fb849 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -1,6 +1,5 @@ package com.moabam.api.application.coupon; -import static com.moabam.support.fixture.CouponFixture.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; @@ -18,6 +17,7 @@ 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; @@ -30,14 +30,11 @@ 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.model.AuthMember; -import com.moabam.global.auth.model.AuthorizationThreadLocal; 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.annotation.WithMember; import com.moabam.support.common.FilterProcessExtension; import com.moabam.support.fixture.CouponFixture; @@ -48,10 +45,10 @@ class CouponServiceTest { CouponService couponService; @Mock - CouponManageService couponManageService; + BugService bugService; @Mock - BugService bugService; + CouponManageService couponManageService; @Mock CouponRepository couponRepository; @@ -68,94 +65,82 @@ class CouponServiceTest { @Mock ClockHolder clockHolder; - @WithMember(role = Role.ADMIN) - @DisplayName("쿠폰을 성공적으로 발행한다. - Void") + @DisplayName("관리자가 쿠폰을 성공적으로 발행한다. - Void") @Test void create_success() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); given(couponRepository.existsByName(any(String.class))).willReturn(false); given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When - couponService.create(admin, request); + couponService.create(request, 1L, Role.ADMIN); // Then verify(couponRepository).save(any(Coupon.class)); } - @WithMember(role = Role.USER) @DisplayName("권한 없는 사용자가 쿠폰을 발행한다. - NotFoundException") @Test void create_Admin_NotFoundException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); // When & Then - assertThatThrownBy(() -> couponService.create(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.USER)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰 종류를 발행한다. - NotFoundException") @Test void create_Type_NotFoundException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); 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(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_COUPON_TYPE.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("중복된 쿠폰명을 발행한다. - ConflictException") @Test void create_Name_ConflictException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); given(couponRepository.existsByName(any(String.class))).willReturn(true); // When & Then - assertThatThrownBy(() -> couponService.create(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_COUPON_NAME.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("중복된 쿠폰 발행 가능 날짜를 발행한다. - ConflictException") @Test void create_StartAt_ConflictException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); 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(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_COUPON_START_AT.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("현재 날짜가 쿠폰 발급 가능 날짜와 같거나 이후이다. - BadRequestException") @Test void create_StartAt_BadRequestException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); CreateCouponRequest request = CouponFixture.createCouponRequest(); given(clockHolder.date()).willReturn(LocalDate.of(2025, 1, 1)); @@ -163,17 +148,15 @@ void create_StartAt_BadRequestException() { given(couponRepository.existsByStartAt(any(LocalDate.class))).willReturn(false); // When & Then - assertThatThrownBy(() -> couponService.create(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_START_AT_PERIOD.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 정보 오픈 날짜가 쿠폰 발급 시작 날짜와 같거나 이후인 쿠폰을 발행한다. - BadRequestException") @Test void create_OpenAt_BadRequestException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); String couponType = CouponType.GOLDEN.getName(); CreateCouponRequest request = CouponFixture.createCouponRequest(couponType, 1, 1); @@ -182,53 +165,44 @@ void create_OpenAt_BadRequestException() { given(clockHolder.date()).willReturn(LocalDate.of(2022, 1, 1)); // When & Then - assertThatThrownBy(() -> couponService.create(admin, request)) + assertThatThrownBy(() -> couponService.create(request, 1L, Role.ADMIN)) .isInstanceOf(BadRequestException.class) .hasMessage(ErrorMessage.INVALID_COUPON_OPEN_AT_PERIOD.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("쿠폰 아이디와 일치하는 쿠폰을 성공적으로 삭제한다. - Void") @Test void delete_success() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); Coupon coupon = CouponFixture.coupon(10, 100); given(couponRepository.findById(any(Long.class))).willReturn(Optional.of(coupon)); // When - couponService.delete(admin, 1L); + couponService.delete(1L, Role.ADMIN); // Then verify(couponRepository).delete(coupon); verify(couponManageService).deleteQueue(any(String.class)); } - @WithMember(role = Role.USER) @DisplayName("권한 없는 사용자가 쿠폰을 삭제한다. - NotFoundException") @Test void delete_Admin_NotFoundException() { - // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - // When & Then - assertThatThrownBy(() -> couponService.delete(admin, 1L)) + assertThatThrownBy(() -> couponService.delete(1L, Role.USER)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); } - @WithMember(role = Role.ADMIN) @DisplayName("존재하지 않는 쿠폰 아이디를 삭제하려고 시도한다. - NotFoundException") @Test void delete_NotFoundException() { // Given - AuthMember admin = AuthorizationThreadLocal.getAuthMember(); - given(couponRepository.findById(any(Long.class))).willReturn(Optional.empty()); // When & Then - assertThatThrownBy(() -> couponService.delete(admin, 1L)) + assertThatThrownBy(() -> couponService.delete(1L, Role.ADMIN)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_COUPON.getMessage()); } @@ -279,39 +253,36 @@ void getAllByStatus_success(List coupons) { assertThat(actual).hasSize(coupons.size()); } - @WithMember - @DisplayName("나의 쿠폰함을 성공적으로 조회한다.") + @DisplayName("나의 모든 쿠폰을 성공적으로 조회한다.") @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletByCouponId1_total5") @ParameterizedTest - void getWallet_success(List couponWallets) { + void getAllByWalletIdAndMemberId_all_success(List couponWallets) { // Given - AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); - - given(couponWalletSearchRepository.findAllByCouponIdAndMemberId(isNull(), any(Long.class))) + given(couponWalletSearchRepository.findAllByIdAndMemberId(isNull(), any(Long.class))) .willReturn(couponWallets); // When - List actual = couponService.getWallet(null, authMember); + List actual = couponService.getAllByWalletIdAndMemberId(null, 1L); // Then assertThat(actual).hasSize(couponWallets.size()); } - @WithMember - @DisplayName("지갑에서 특정 쿠폰을 성공적으로 조회한다.") + @DisplayName("나의 특정 쿠폰을 성공적으로 조회한다.") @Test - void getByWalletIdAndMemberId_success() { + void getAllByWalletIdAndMemberId_success() { // Given - CouponWallet couponWallet = CouponWallet.create(1L, coupon()); + Coupon coupon = CouponFixture.coupon(); + List couponWallets = List.of(CouponWallet.create(1L, coupon)); - given(couponWalletSearchRepository.findByIdAndMemberId(any(Long.class), any(Long.class))) - .willReturn(Optional.of(couponWallet)); + given(couponWalletSearchRepository.findAllByIdAndMemberId(any(Long.class), any(Long.class))) + .willReturn(couponWallets); // When - CouponWallet actual = couponService.getWalletByIdAndMemberId(1L, 1L); + List actual = couponService.getAllByWalletIdAndMemberId(1L, 1L); // Then - assertThat(actual.getCoupon().getName()).isEqualTo(couponWallet.getCoupon().getName()); + assertThat(actual).hasSize(1); } @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있는 특정 쿠폰을 성공적으로 사용한다. - Void") @@ -329,6 +300,7 @@ void use_success() { // Then verify(couponWalletRepository).delete(any(CouponWallet.class)); + verify(bugService).applyCoupon(any(Long.class), any(BugType.class), any(int.class)); } @DisplayName("특정 회원이 쿠폰 지갑에 가지고 있는 할인 쿠폰을 사용한다. - BadRequestException") diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 9b411f8a..2a49ab6f 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -53,71 +53,59 @@ class NotificationServiceTest { String successIssueResult = "%s 쿠폰 발행을 성공했습니다. 축하드립니다!"; - @WithMember @DisplayName("상대에게 콕 알림을 성공적으로 보낸다. - Void") @Test void sendKnock_success() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - willDoNothing().given(roomService).validateRoomById(any(Long.class)); 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(member, 2L, 1L); + notificationService.sendKnock(1L, 1L, 2L, "nickName"); // Then verify(fcmService).sendAsync(any(String.class), any(String.class)); verify(notificationRepository).saveKnock(any(Long.class), any(Long.class), any(Long.class)); } - @WithMember @DisplayName("콕 찌를 상대의 방이 존재하지 않는다. - NotFoundException") @Test void sendKnock_Room_NotFoundException() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); // When & Then - assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L, "nickName")) .isInstanceOf(NotFoundException.class); } - @WithMember @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") @Test void sendKnock_FcmToken_NotFoundException() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - willDoNothing().given(roomService).validateRoomById(any(Long.class)); 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(member, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L, "nickName")) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); } - @WithMember @DisplayName("콕 찌를 상대가 이미 찌른 상대이다. - ConflictException") @Test void sendKnock_ConflictException() { // Given - AuthMember member = AuthorizationThreadLocal.getAuthMember(); - willDoNothing().given(roomService).validateRoomById(any(Long.class)); given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) .willReturn(true); // When & Then - assertThatThrownBy(() -> notificationService.sendKnock(member, 1L, 1L)) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L, "nickName")) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index 0f992e1d..039563e2 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -102,7 +102,7 @@ void use_coupon_success() { paymentService.confirm(memberId, request); // then - verify(couponService, times(1)).discount(memberId, couponWalletId); + verify(couponService, times(1)).discount(couponWalletId, memberId); verify(bugService, times(1)).charge(memberId, payment.getProduct()); } 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 index 82d1fd5c..2770be82 100644 --- a/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/repository/CouponWalletSearchRepositoryTest.java @@ -31,15 +31,16 @@ class CouponWalletSearchRepositoryTest { @Autowired private CouponWalletSearchRepository couponWalletSearchRepository; - @DisplayName("나의 쿠폰함의 특정 쿠폰을 조회한다.. - List") + @DisplayName("나의 쿠폰함의 특정 쿠폰을 조회한다. - List") @Test - void findAllByCouponIdAndMemberId_success() { + void findAllByIdAndMemberId_success() { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon()); CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); // When - List actual = couponWalletSearchRepository.findAllByCouponIdAndMemberId(coupon.getId(), 1L); + List actual = couponWalletSearchRepository + .findAllByIdAndMemberId(couponWallet.getId(), 1L); // Then assertThat(actual).hasSize(1); @@ -50,7 +51,7 @@ void findAllByCouponIdAndMemberId_success() { @DisplayName("ID가 1인 회원은 쿠폰 1개를 가지고 있다. - List") @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") @ParameterizedTest - void findAllByCouponIdAndMemberId_Id1_success(List couponWallets) { + void findAllByIdAndMemberId_Id1_success(List couponWallets) { // Given couponWallets.forEach(couponWallet -> { Coupon coupon = couponRepository.save(couponWallet.getCoupon()); @@ -58,7 +59,7 @@ void findAllByCouponIdAndMemberId_Id1_success(List couponWallets) }); // When - List actual = couponWalletSearchRepository.findAllByCouponIdAndMemberId(null, 1L); + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(null, 1L); // Then assertThat(actual).hasSize(1); @@ -67,7 +68,7 @@ void findAllByCouponIdAndMemberId_Id1_success(List couponWallets) @DisplayName("ID가 2인 회원은 쿠폰 ID가 777인 쿠폰을 가지고 있지 않다. - List") @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") @ParameterizedTest - void findAllByCouponIdAndMemberId_Id2_notCouponId777(List couponWallets) { + void findAllByIdAndMemberId_Id2_notCouponId777(List couponWallets) { // Given couponWallets.forEach(couponWallet -> { Coupon coupon = couponRepository.save(couponWallet.getCoupon()); @@ -75,7 +76,7 @@ void findAllByCouponIdAndMemberId_Id2_notCouponId777(List couponWa }); // When - List actual = couponWalletSearchRepository.findAllByCouponIdAndMemberId(777L, 2L); + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(777L, 2L); // Then assertThat(actual).isEmpty(); @@ -84,7 +85,7 @@ void findAllByCouponIdAndMemberId_Id2_notCouponId777(List couponWa @DisplayName("ID가 3인 회원은 쿠폰 3개를 가지고 있다. - List") @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletAll") @ParameterizedTest - void findAllByCouponIdAndMemberId_Id3_success(List couponWallets) { + void findAllByIdAndMemberId_Id3_success(List couponWallets) { // Given couponWallets.forEach(couponWallet -> { Coupon coupon = couponRepository.save(couponWallet.getCoupon()); @@ -92,7 +93,7 @@ void findAllByCouponIdAndMemberId_Id3_success(List couponWallets) }); // When - List actual = couponWalletSearchRepository.findAllByCouponIdAndMemberId(null, 3L); + List actual = couponWalletSearchRepository.findAllByIdAndMemberId(null, 3L); // Then assertThat(actual).hasSize(3); diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java index 83f2e887..6eed5267 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmRepositoryTest.java @@ -28,7 +28,7 @@ class FcmRepositoryTest { @Test void saveToken_success() { // When - fcmRepository.saveToken(1L, "value1"); + fcmRepository.saveToken("FCM-TOKEN", 1L); // Then verify(valueRedisRepository).save(any(String.class), any(String.class), any(Duration.class)); @@ -38,7 +38,7 @@ void saveToken_success() { @Test void saveToken_MemberId_NullPointerException() { // When & Then - assertThatThrownBy(() -> fcmRepository.saveToken(null, "value")) + assertThatThrownBy(() -> fcmRepository.saveToken("FCM-TOKEN", null)) .isInstanceOf(NullPointerException.class); } @@ -46,7 +46,7 @@ void saveToken_MemberId_NullPointerException() { @Test void saveToken_FcmToken_NullPointerException() { // When & Then - assertThatThrownBy(() -> fcmRepository.saveToken(1L, null)) + assertThatThrownBy(() -> fcmRepository.saveToken(null, 1L)) .isInstanceOf(NullPointerException.class); } @@ -60,7 +60,7 @@ void deleteTokenByMemberId_success() { verify(valueRedisRepository).delete(any(String.class)); } - @DisplayName("ID가 Null인 사용자가 FCM 토큰을 삭제한다.. - NullPointerException") + @DisplayName("ID가 Null인 사용자가 FCM 토큰을 삭제한다. - NullPointerException") @Test void deleteTokenByMemberId_NullPointerException() { // When & Then @@ -85,22 +85,4 @@ void findTokenByMemberId_NullPointerException() { assertThatThrownBy(() -> fcmRepository.findTokenByMemberId(null)) .isInstanceOf(NullPointerException.class); } - - @DisplayName("FCM 토큰 존재 여부를 성공적으로 확인한다. - Boolean") - @Test - void existsTokenByMemberId_success() { - // When - fcmRepository.existsTokenByMemberId(1L); - - // Then - verify(valueRedisRepository).hasKey(any(String.class)); - } - - @DisplayName("ID가 Null인 사용자가 FCM 토큰 존재 여부를 확인한다. - NullPointerException") - @Test - void existsTokenByMemberId_NullPointerException() { - // When & Then - assertThatThrownBy(() -> fcmRepository.existsTokenByMemberId(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 index c4b7cc09..942cf849 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -10,10 +10,7 @@ import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.Message; -import com.moabam.global.auth.model.AuthMember; -import com.moabam.global.auth.model.AuthorizationThreadLocal; import com.moabam.global.config.FcmConfig; -import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; @SpringBootTest(classes = {FcmConfig.class, FcmService.class}) @@ -28,46 +25,34 @@ class FcmServiceTest extends WithoutFilterSupporter { @MockBean FcmRepository fcmRepository; - @WithMember @DisplayName("FCM 토큰이 성공적으로 저장된다. - Void") @Test void saveToken_success() { - // Given - AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); - // When - fcmService.createToken(authMember, "value1"); + fcmService.createToken("FCM-TOKEN", 1L); // Then - verify(fcmRepository).saveToken(any(Long.class), any(String.class)); + verify(fcmRepository).saveToken(any(String.class), any(Long.class)); } - @WithMember @DisplayName("FCM 토큰으로 빈값이 넘어와 아무일도 일어나지 않는다. - Void") @Test void saveToken_Blank() { - // Given - AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); - // When - fcmService.createToken(authMember, ""); + fcmService.createToken("", 1L); // Then - verify(fcmRepository, times(0)).saveToken(any(Long.class), any(String.class)); + verify(fcmRepository, times(0)).saveToken(any(String.class), any(Long.class)); } - @WithMember @DisplayName("FCM 토큰으로 null이 넘어와 아무일도 일어나지 않는다. - Void") @Test void saveToken_Null() { - // Given - AuthMember authMember = AuthorizationThreadLocal.getAuthMember(); - // When - fcmService.createToken(authMember, null); + fcmService.createToken(null, 1L); // Then - verify(fcmRepository, times(0)).saveToken(any(Long.class), any(String.class)); + verify(fcmRepository, times(0)).saveToken(any(String.class), any(Long.class)); } @DisplayName("FCM 토큰이 성공적으로 삭제된다. - Void") diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 2928f6e7..757f79c3 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -11,7 +11,6 @@ import java.time.LocalDate; import java.util.List; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -36,6 +35,7 @@ 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.ZSetRedisRepository; import com.moabam.global.common.util.ClockHolder; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; @@ -70,6 +70,9 @@ class CouponControllerTest extends WithoutFilterSupporter { @MockBean ClockHolder clockHolder; + @MockBean + ZSetRedisRepository zSetRedisRepository; + @WithMember(role = Role.ADMIN) @DisplayName("POST - 쿠폰을 성공적으로 발행한다. - Void") @Test @@ -301,105 +304,16 @@ void getAllByStatus_Coupon_success(List coupons) throws Exception { .andExpect(jsonPath("$", hasSize(1))); } - @Disabled - @WithMember - @DisplayName("POST - 쿠폰 발급 요청을 성공적으로 한다. - Void") - @Test - void registerQueue_success() throws Exception { - // Given - Coupon couponFixture = CouponFixture.coupon(); - Coupon coupon = couponRepository.save(couponFixture); - - given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 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 - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not found) - BadRequestException") - @Test - void registerQueue_Zero_StartAt_BadRequestException() 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().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); - } - - @WithMember - @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not equals) - BadRequestException") - @Test - void registerQueue_Not_StartAt_BadRequestException() throws Exception { - // Given - Coupon couponFixture = CouponFixture.coupon(); - couponRepository.save(couponFixture); - - given(clockHolder.date()).willReturn(LocalDate.of(2022, 2, 1)); - - // When & Then - mockMvc.perform(post("/coupons") - .param("couponName", "not start couponName")) - .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_PERIOD.getMessage())); - } - - @WithMember - @DisplayName("POST - 존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") - @Test - void registerQueue_NotFoundException() throws Exception { - // Given - Coupon coupon = CouponFixture.coupon("Not found couponName", 2, 1); - - given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 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().isBadRequest()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); - } - @WithMember @DisplayName("GET - 나의 쿠폰함에서 특정 쿠폰을 조회한다. - List") @Test - void getWallet_success() throws Exception { + void getAllByWalletIdAndMemberId_success() throws Exception { // Given Coupon coupon = couponRepository.save(CouponFixture.coupon()); - couponWalletRepository.save(CouponWallet.create(1L, coupon)); + CouponWallet couponWallet = couponWalletRepository.save(CouponWallet.create(1L, coupon)); // When & Then - mockMvc.perform(get("/my-coupons/" + coupon.getId())) + mockMvc.perform(get("/my-coupons/" + couponWallet.getId())) .andDo(print()) .andDo(document("my-coupons/couponId", preprocessRequest(prettyPrint()), @@ -416,7 +330,7 @@ void getWallet_success() throws Exception { @DisplayName("GET - 나의 쿠폰 보관함에 있는 모든 쿠폰을 조회한다. - List") @MethodSource("com.moabam.support.fixture.CouponWalletFixture#provideCouponWalletByCouponId1_total5") @ParameterizedTest - void getWallet_all_success(List couponWallets) throws Exception { + void getAllByWalletIdAndMemberId_all_success(List couponWallets) throws Exception { // Given couponWallets.forEach(couponWallet -> { Coupon coupon = couponRepository.save(couponWallet.getCoupon()); @@ -438,7 +352,7 @@ void getWallet_all_success(List couponWallets) throws Exception { @WithMember @DisplayName("GET - 쿠폰이 없는 사용자의 쿠폰함을 조회한다. - List") @Test - void getWallet_no_coupon() throws Exception { + void getAllByWalletIdAndMemberId_no_coupon() throws Exception { // When & Then mockMvc.perform(get("/my-coupons")) .andDo(print()) @@ -504,4 +418,93 @@ void use_BadRequestException() throws Exception { .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(); + Coupon coupon = couponRepository.save(couponFixture); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); + willDoNothing().given(zSetRedisRepository).addIfAbsent(anyString(), anyLong(), anyDouble(), anyInt()); + + // 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 - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not found) - BadRequestException") + @Test + void registerQueue_Zero_StartAt_BadRequestException() 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().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + } + + @WithMember + @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not equals) - BadRequestException") + @Test + void registerQueue_Not_StartAt_BadRequestException() throws Exception { + // Given + Coupon couponFixture = CouponFixture.coupon(); + couponRepository.save(couponFixture); + + given(clockHolder.date()).willReturn(LocalDate.of(2022, 2, 1)); + + // When & Then + mockMvc.perform(post("/coupons") + .param("couponName", "not start couponName")) + .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_PERIOD.getMessage())); + } + + @WithMember + @DisplayName("POST - 존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") + @Test + void registerQueue_NotFoundException() throws Exception { + // Given + Coupon coupon = CouponFixture.coupon("Not found couponName", 2, 1); + + given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 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().isBadRequest()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + } } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 95581ddf..3df0a842 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -121,7 +121,7 @@ void createFcmToken_blank() throws Exception { @Test void sendKnock_success() throws Exception { // Given - fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); + fcmRepository.saveToken("FCM_TOKEN", target.getId()); // When & Then mockMvc.perform(get("/notifications/rooms/" + room.getId() + "/members/" + target.getId())) @@ -153,7 +153,7 @@ void sendKnock_NotFoundException() throws Exception { @Test void sendKnock_ConflictException() throws Exception { // Given - fcmRepository.saveToken(target.getId(), "FCM_TOKEN"); + fcmRepository.saveToken("FCM_TOKEN", target.getId()); notificationRepository.saveKnock(1L, target.getId(), room.getId()); // When & Then diff --git a/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java b/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java index f6d260be..8e1b0c1e 100644 --- a/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java +++ b/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java @@ -8,6 +8,7 @@ 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 가능)"), From 458e67b59b7e9c11b79e4e51e4a3ee7871987903 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Wed, 29 Nov 2023 22:00:31 +0900 Subject: [PATCH 113/185] =?UTF-8?q?fix:=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=A9=A4=EB=B2=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=20=EC=8A=A4=ED=82=A8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20(#182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/application/item/ItemService.java | 3 ++- .../com/moabam/api/domain/item/Inventory.java | 4 +++- .../api/application/item/ItemServiceTest.java | 1 + .../InventorySearchRepositoryTest.java | 10 +++++----- .../api/presentation/ItemControllerTest.java | 3 ++- .../api/presentation/MemberControllerTest.java | 18 +++++++++--------- .../api/presentation/RoomControllerTest.java | 8 ++++---- 7 files changed, 26 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/moabam/api/application/item/ItemService.java b/src/main/java/com/moabam/api/application/item/ItemService.java index 8e0f9f72..b0f7f0f9 100644 --- a/src/main/java/com/moabam/api/application/item/ItemService.java +++ b/src/main/java/com/moabam/api/application/item/ItemService.java @@ -60,11 +60,12 @@ public void purchaseItem(Long memberId, Long itemId, PurchaseItemRequest request @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(); + inventory.select(member); } private Item getItem(Long itemId) { diff --git a/src/main/java/com/moabam/api/domain/item/Inventory.java b/src/main/java/com/moabam/api/domain/item/Inventory.java index c17d507b..44c6ab26 100644 --- a/src/main/java/com/moabam/api/domain/item/Inventory.java +++ b/src/main/java/com/moabam/api/domain/item/Inventory.java @@ -4,6 +4,7 @@ import org.hibernate.annotations.ColumnDefault; +import com.moabam.api.domain.member.Member; import com.moabam.global.common.entity.BaseTimeEntity; import jakarta.persistence.Column; @@ -54,8 +55,9 @@ public ItemType getItemType() { return this.item.getType(); } - public void select() { + public void select(Member member) { this.isDefault = true; + member.changeDefaultSkintUrl(this.item); } public void deselect() { diff --git a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java index dca4b164..280c70fe 100644 --- a/src/test/java/com/moabam/api/application/item/ItemServiceTest.java +++ b/src/test/java/com/moabam/api/application/item/ItemServiceTest.java @@ -160,6 +160,7 @@ void success() { 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)); 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 index 1e6a3299..649a73e7 100644 --- a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -103,7 +103,7 @@ void find_default_success() { Member member = memberRepository.save(member("11314", "test")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); - inventory.select(); + inventory.select(member); // when Optional actual = inventorySearchRepository.findDefault(member.getId(), inventory.getItemType()); @@ -121,8 +121,8 @@ void find_all_default_type_night_success() { Item item = itemRepository.save(nightMageSkin()); Inventory inventory1 = inventoryRepository.save(inventory(member1.getId(), item)); Inventory inventory2 = inventoryRepository.save(inventory(member2.getId(), item)); - inventory1.select(); - inventory2.select(); + inventory1.select(member1); + inventory2.select(member2); // when List actual = inventorySearchRepository.findDefaultInventories(List.of(member1.getId(), @@ -151,10 +151,10 @@ void bird_find_success() { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(member.getId(), night); - nightInven.select(); + nightInven.select(member); Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); - morningInven.select(); + morningInven.select(member); Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); diff --git a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java index 3cdd53d7..4745e997 100644 --- a/src/test/java/com/moabam/api/presentation/ItemControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ItemControllerTest.java @@ -76,7 +76,7 @@ void success() throws Exception { Long memberId = getAuthMember().id(); Item item1 = itemRepository.save(morningSantaSkin().build()); Inventory inventory = inventoryRepository.save(inventory(memberId, item1)); - inventory.select(); + inventory.select(member()); Item item2 = itemRepository.save(morningKillerSkin().build()); ItemsResponse expected = ItemMapper.toItemsResponse(item1.getId(), List.of(item1), List.of(item2)); @@ -153,6 +153,7 @@ 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 diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 659bc5a8..f9ce684a 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -266,10 +266,10 @@ void search_my_info_success() throws Exception { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(member.getId(), night); - nightInven.select(); + nightInven.select(member); Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); - morningInven.select(); + morningInven.select(member); Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); @@ -316,10 +316,10 @@ void search_my_info_with_no_badge_success() throws Exception { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(member.getId(), night); - nightInven.select(); + nightInven.select(member); Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); - morningInven.select(); + morningInven.select(member); Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); @@ -377,10 +377,10 @@ void search_friend_info_success() throws Exception { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(friend.getId(), night); - nightInven.select(); + nightInven.select(member); Inventory morningInven = InventoryFixture.inventory(friend.getId(), morning); - morningInven.select(); + morningInven.select(member); Inventory killerInven = InventoryFixture.inventory(friend.getId(), killer); friend.changeDefaultSkintUrl(morning); @@ -435,13 +435,13 @@ void search_member_failBy_default_skin_size() throws Exception { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(member.getId(), night); - nightInven.select(); + nightInven.select(member); Inventory morningInven = InventoryFixture.inventory(member.getId(), morning); - morningInven.select(); + morningInven.select(member); Inventory killerInven = InventoryFixture.inventory(member.getId(), killer); - killerInven.select(); + killerInven.select(member); inventoryRepository.saveAll(List.of(nightInven, morningInven, killerInven)); // expected diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 0577f001..61170e45 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -807,9 +807,9 @@ void get_room_details_test() throws Exception { Inventory inventory1 = InventoryFixture.inventory(1L, item); Inventory inventory2 = InventoryFixture.inventory(member2.getId(), item); Inventory inventory3 = InventoryFixture.inventory(member3.getId(), item); - inventory1.select(); - inventory2.select(); - inventory3.select(); + inventory1.select(member); + inventory2.select(member2); + inventory3.select(member3); itemRepository.save(item); inventoryRepository.saveAll(List.of(inventory1, inventory2, inventory3)); @@ -1017,7 +1017,7 @@ void get_un_joined_room_details() throws Exception { Item item = ItemFixture.nightMageSkin(); Inventory inventory = InventoryFixture.inventory(member1.getId(), item); - inventory.select(); + inventory.select(member1); itemRepository.save(item); inventoryRepository.save(inventory); From d20bf177e020ef3c8493b5dae01f05f103455ddc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Wed, 29 Nov 2023 22:39:45 +0900 Subject: [PATCH 114/185] =?UTF-8?q?style:=20FCM=20Token=20Log=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: coupon 발행 및 삭제 스타일 변경 * refactor: My Coupon 조회 코드 개선 * refactor: 쿠폰 등록, 사용 코드 개선 * refactor: FCM 및 알림 코드 개선 * style: fcm token log --- .../java/com/moabam/api/infrastructure/fcm/FcmService.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java index cc575968..6fc15f68 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -9,7 +9,9 @@ import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Service @RequiredArgsConstructor public class FcmService { @@ -22,7 +24,9 @@ public void createToken(String fcmToken, Long memberId) { return; } + log.info("FCM TOKEN before: " + fcmToken); fcmRepository.saveToken(fcmToken, memberId); + log.info("FCM TOKEN after: " + findTokenByMemberId(memberId)); } public void deleteTokenByMemberId(Long memberId) { From 61917fd6132104ec7150405644761945da170969 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Wed, 29 Nov 2023 22:49:00 +0900 Subject: [PATCH 115/185] =?UTF-8?q?fix:=20=EB=B0=A9=EC=9E=A5=20=EB=B0=A9?= =?UTF-8?q?=20=EB=82=98=EA=B0=80=EA=B8=B0=20=EC=BD=94=EB=93=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#184)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/application/room/RoomService.java | 2 ++ .../java/com/moabam/api/presentation/RoomControllerTest.java | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index d14228a6..b4a90fcd 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -116,6 +116,8 @@ public void exitRoom(Long memberId, Long roomId) { return; } + List routines = routineRepository.findAllByRoomId(roomId); + routineRepository.deleteAll(routines); roomRepository.delete(room); } diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 61170e45..c922bc93 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -663,10 +663,13 @@ void manager_delete_room_success() throws Exception { .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 @@ -675,9 +678,11 @@ void manager_delete_room_success() throws Exception { .andDo(print()); List deletedRoom = roomRepository.findAll(); + List deletedRoutine = routineRepository.findAll(); List deletedParticipant = participantRepository.findAll(); assertThat(deletedRoom).isEmpty(); + assertThat(deletedRoutine).hasSize(0); assertThat(deletedParticipant).hasSize(1); assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull(); assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull(); From 6a39b9d45670b6236e6da804b3a796fbd0549d72 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Wed, 29 Nov 2023 23:43:09 +0900 Subject: [PATCH 116/185] =?UTF-8?q?hotfix:=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/presentation/RoomController.java | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 2581e5e1..4d2b609b 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,7 +1,7 @@ package com.moabam.api.presentation; import java.time.LocalDate; -import java.util.List; +import java.util.Map; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; @@ -21,9 +21,7 @@ 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.room.RoomType; -import com.moabam.api.dto.room.CertifiedMemberInfo; import com.moabam.api.dto.room.CreateRoomRequest; import com.moabam.api.dto.room.EnterRoomRequest; import com.moabam.api.dto.room.GetAllRoomsResponse; @@ -38,9 +36,11 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; @RestController @RequiredArgsConstructor +@Slf4j @RequestMapping("/rooms") public class RoomController { @@ -110,10 +110,14 @@ public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, @PathVari @PostMapping("/{roomId}/certification") @ResponseStatus(HttpStatus.CREATED) public void certifyRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, - @RequestPart List multipartFiles) { - List imageUrls = imageService.uploadImages(multipartFiles, ImageType.CERTIFICATION); - CertifiedMemberInfo info = certificationService.getCertifiedMemberInfo(authMember.id(), roomId, imageUrls); - certificationService.certifyRoom(info); + @RequestPart("file") Map multipartFiles) { + + log.info("multipartFiles Size = {}", multipartFiles.size()); + log.info(multipartFiles.toString()); + + // List imageUrls = imageService.uploadImages(multipartFiles, ImageType.CERTIFICATION); + // CertifiedMemberInfo info = certificationService.getCertifiedMemberInfo(authMember.id(), roomId, imageUrls); + // certificationService.certifyRoom(info); } @PutMapping("/{roomId}/members/{memberId}/mandate") From 7a5b178badb9a07d453dd484df5757b5ee06b747 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Thu, 30 Nov 2023 00:04:59 +0900 Subject: [PATCH 117/185] =?UTF-8?q?hotfix:=20=EB=B0=A9=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=A9=20=EC=82=AD=EC=A0=9C=20=EB=B2=84=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/moabam/api/application/room/RoomService.java | 1 + src/main/java/com/moabam/api/presentation/RoomController.java | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index b4a90fcd..e68b4b7a 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -143,6 +143,7 @@ public void deportParticipant(Long managerId, Long roomId, Long memberId) { validateManagerAuthorization(managerParticipant); Room room = managerParticipant.getRoom(); + memberParticipant.removeRoom(); participantRepository.delete(memberParticipant); room.decreaseCurrentUserCount(); diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 4d2b609b..66bb4bed 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -110,7 +110,7 @@ public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, @PathVari @PostMapping("/{roomId}/certification") @ResponseStatus(HttpStatus.CREATED) public void certifyRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, - @RequestPart("file") Map multipartFiles) { + @RequestPart(name = "file") Map multipartFiles) { log.info("multipartFiles Size = {}", multipartFiles.size()); log.info(multipartFiles.toString()); From bd1c3b6f1f1dacf38c9563ddb24453d1de4fe292 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 30 Nov 2023 00:28:51 +0900 Subject: [PATCH 118/185] =?UTF-8?q?fix:=20fcm=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Base64관련 디코딩 코드 변경 -> Base64Url * refactor: 쿠폰 스케쥴 업데이트 및 config 수정 * style: 문자열 checkstyle 수정 * fix: 회원 탈퇴시 방 참여에 대한 문제 해결 * refactor: config update * test: 신고 실패에 대한 테스트 코드 변경 * feat: fcm 토큰 제거 기능 추가 * style: 필요없는 로그 제거 * fix: 참여자 업데이트 --- .../moabam/api/application/auth/AuthorizationService.java | 3 +++ .../com/moabam/api/application/member/MemberService.java | 3 +++ .../java/com/moabam/api/application/room/RoomService.java | 1 + .../java/com/moabam/api/infrastructure/fcm/FcmService.java | 4 ---- .../com/moabam/global/auth/filter/AuthorizationFilter.java | 1 - .../api/application/auth/AuthorizationServiceTest.java | 6 +++++- .../moabam/api/application/member/MemberServiceTest.java | 4 ++++ 7 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index a13ae804..34234f94 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -21,6 +21,7 @@ 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.GlobalConstant; @@ -41,6 +42,7 @@ @RequiredArgsConstructor public class AuthorizationService { + private final FcmService fcmService; private final OAuthConfig oAuthConfig; private final TokenConfig tokenConfig; private final OAuth2AuthorizationServerRequestService oauth2AuthorizationServerRequestService; @@ -105,6 +107,7 @@ public void logout(AuthMember authMember, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { removeToken(httpServletRequest, httpServletResponse); tokenRepository.delete(authMember.id()); + fcmService.deleteTokenByMemberId(authMember.id()); } public void removeToken(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 2c53f243..8ed67cc5 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -26,6 +26,7 @@ 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.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthMember; import com.moabam.global.common.util.BaseDataCode; import com.moabam.global.common.util.ClockHolder; @@ -41,6 +42,7 @@ @RequiredArgsConstructor public class MemberService { + private final FcmService fcmService; private final MemberRepository memberRepository; private final InventoryRepository inventoryRepository; private final ItemRepository itemRepository; @@ -83,6 +85,7 @@ public void delete(Member member) { member.delete(clockHolder.times()); memberRepository.flush(); memberRepository.delete(member); + fcmService.deleteTokenByMemberId(member.getId()); } public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index e68b4b7a..658d7e0e 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -144,6 +144,7 @@ public void deportParticipant(Long managerId, Long roomId, Long memberId) { Room room = managerParticipant.getRoom(); memberParticipant.removeRoom(); + participantRepository.flush(); participantRepository.delete(memberParticipant); room.decreaseCurrentUserCount(); diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java index 6fc15f68..cc575968 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -9,9 +9,7 @@ import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -@Slf4j @Service @RequiredArgsConstructor public class FcmService { @@ -24,9 +22,7 @@ public void createToken(String fcmToken, Long memberId) { return; } - log.info("FCM TOKEN before: " + fcmToken); fcmRepository.saveToken(fcmToken, memberId); - log.info("FCM TOKEN after: " + findTokenByMemberId(memberId)); } public void deleteTokenByMemberId(Long memberId) { diff --git a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java index 6c9870e4..325afe37 100644 --- a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java @@ -50,7 +50,6 @@ protected void doFilterInternal(@NotNull HttpServletRequest httpServletRequest, try { invoke(httpServletRequest, httpServletResponse); } catch (UnauthorizedException unauthorizedException) { - log.error("Login Failed"); authorizationService.removeToken(httpServletRequest, httpServletResponse); handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, unauthorizedException); diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index 8824a8c5..b4d2e47d 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -33,6 +33,7 @@ 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.cookie.CookieDevUtils; @@ -66,6 +67,9 @@ class AuthorizationServiceTest { @Mock JwtProviderService jwtProviderService; + @Mock + FcmService fcmService; + @Mock TokenRepository tokenRepository; @@ -93,7 +97,7 @@ public void initParams() { noOAuthConfig = new OAuthConfig( new OAuthConfig.Provider(null, null, null, null, null), new OAuthConfig.Client(null, null, null, null, null, null)); - noPropertyService = new AuthorizationService(noOAuthConfig, tokenConfig, + noPropertyService = new AuthorizationService(fcmService, noOAuthConfig, tokenConfig, oAuth2AuthorizationServerRequestService, memberService, jwtProviderService, tokenRepository, cookieUtils); } diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index c4a4d6cc..bdf62154 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -31,6 +31,7 @@ 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; @@ -68,6 +69,9 @@ class MemberServiceTest { @Mock InventoryRepository inventoryRepository; + @Mock + FcmService fcmService; + @Mock ItemRepository itemRepository; From 3408228766fbef786388ea68993d699707c214cd Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 30 Nov 2023 01:05:13 +0900 Subject: [PATCH 119/185] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=B9=EC=9D=B8=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20(#188)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 토스 결제 승인 실패 시 예외 throw * test: 결제 승인 로직 변경에 따른 테스트 수정 --- .../application/payment/PaymentService.java | 30 ++--- .../moabam/api/domain/payment/Payment.java | 4 +- .../payment/ConfirmTossPaymentRequest.java | 12 -- .../payment/TossPaymentMapper.java | 18 --- .../payment/TossPaymentService.java | 11 +- .../api/presentation/PaymentController.java | 15 ++- .../error/exception/TossPaymentException.java | 8 ++ .../error/handler/GlobalExceptionHandler.java | 3 +- .../payment/PaymentServiceTest.java | 123 +++++++----------- .../payment/TossPaymentServiceTest.java | 6 +- .../presentation/PaymentControllerTest.java | 27 +++- .../support/fixture/PaymentFixture.java | 9 -- 12 files changed, 121 insertions(+), 145 deletions(-) delete mode 100644 src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java delete mode 100644 src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java create mode 100644 src/main/java/com/moabam/global/error/exception/TossPaymentException.java diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java index 6ace0d48..710e37b0 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -13,9 +13,7 @@ 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.infrastructure.payment.TossPaymentMapper; import com.moabam.api.infrastructure.payment.TossPaymentService; -import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; import lombok.RequiredArgsConstructor; @@ -38,24 +36,26 @@ public void request(Long memberId, Long paymentId, PaymentRequest request) { payment.request(request.orderId()); } - @Transactional - public void confirm(Long memberId, ConfirmPaymentRequest request) { + public Payment validateInfo(Long memberId, ConfirmPaymentRequest request) { Payment payment = getByOrderId(request.orderId()); payment.validateInfo(memberId, request.amount()); - try { - ConfirmTossPaymentResponse response = tossPaymentService.confirm( - TossPaymentMapper.toConfirmRequest(request.paymentKey(), request.orderId(), request.amount()) - ); - payment.confirm(response.paymentKey(), response.approvedAt()); + return payment; + } + + @Transactional + public void confirm(Long memberId, Payment payment, ConfirmTossPaymentResponse response) { + payment.confirm(response.paymentKey()); - if (payment.isCouponApplied()) { - couponService.discount(payment.getCouponWalletId(), memberId); - } - bugService.charge(memberId, payment.getProduct()); - } catch (MoabamException exception) { - payment.fail(request.paymentKey()); + if (payment.isCouponApplied()) { + couponService.discount(payment.getCouponWalletId(), memberId); } + bugService.charge(memberId, payment.getProduct()); + } + + @Transactional + public void fail(Payment payment, String paymentKey) { + payment.fail(paymentKey); } private Payment getById(Long paymentId) { diff --git a/src/main/java/com/moabam/api/domain/payment/Payment.java b/src/main/java/com/moabam/api/domain/payment/Payment.java index f2056499..db81105b 100644 --- a/src/main/java/com/moabam/api/domain/payment/Payment.java +++ b/src/main/java/com/moabam/api/domain/payment/Payment.java @@ -133,9 +133,9 @@ public void request(String orderId) { this.requestedAt = LocalDateTime.now(); } - public void confirm(String paymentKey, LocalDateTime approvedAt) { + public void confirm(String paymentKey) { this.paymentKey = paymentKey; - this.approvedAt = approvedAt; + this.approvedAt = LocalDateTime.now(); this.status = PaymentStatus.DONE; } diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java deleted file mode 100644 index 3527f148..00000000 --- a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentRequest.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.moabam.api.dto.payment; - -import lombok.Builder; - -@Builder -public record ConfirmTossPaymentRequest( - String paymentKey, - String orderId, - int amount -) { - -} diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java deleted file mode 100644 index 146036eb..00000000 --- a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentMapper.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.moabam.api.infrastructure.payment; - -import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; - -import lombok.AccessLevel; -import lombok.NoArgsConstructor; - -@NoArgsConstructor(access = AccessLevel.PRIVATE) -public final class TossPaymentMapper { - - public static ConfirmTossPaymentRequest toConfirmRequest(String paymentKey, String orderId, int amount) { - return ConfirmTossPaymentRequest.builder() - .paymentKey(paymentKey) - .orderId(orderId) - .amount(amount) - .build(); - } -} diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java index 2389746e..6c10bd5a 100644 --- a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -6,13 +6,14 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; -import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +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 com.moabam.global.error.exception.TossPaymentException; import com.moabam.global.error.model.ErrorResponse; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import reactor.core.publisher.Mono; @Service +@Transactional(readOnly = true) @RequiredArgsConstructor public class TossPaymentService { @@ -37,13 +39,14 @@ public void init() { .build(); } - public ConfirmTossPaymentResponse confirm(ConfirmTossPaymentRequest request) { + @Transactional + 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 -> Mono.error(new MoabamException(error.message())))) + .flatMap(error -> Mono.error(new TossPaymentException(error.message())))) .bodyToMono(ConfirmTossPaymentResponse.class) .block(); } diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java index e294f6fc..33ea55e4 100644 --- a/src/main/java/com/moabam/api/presentation/PaymentController.java +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -9,10 +9,14 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.payment.PaymentService; +import com.moabam.api.domain.payment.Payment; 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.infrastructure.payment.TossPaymentService; import com.moabam.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; +import com.moabam.global.error.exception.TossPaymentException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,6 +27,7 @@ public class PaymentController { private final PaymentService paymentService; + private final TossPaymentService tossPaymentService; @PostMapping("/{paymentId}") @ResponseStatus(HttpStatus.OK) @@ -34,6 +39,14 @@ public void request(@Auth AuthMember member, @PathVariable Long paymentId, @PostMapping("/confirm") @ResponseStatus(HttpStatus.OK) public void confirm(@Auth AuthMember member, @Valid @RequestBody ConfirmPaymentRequest request) { - paymentService.confirm(member.id(), request); + Payment payment = paymentService.validateInfo(member.id(), request); + + try { + ConfirmTossPaymentResponse response = tossPaymentService.confirm(request); + paymentService.confirm(member.id(), payment, response); + } catch (TossPaymentException exception) { + paymentService.fail(payment, request.paymentKey()); + throw exception; + } } } 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/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 7218b312..d45037c4 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -21,6 +21,7 @@ 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; @@ -58,7 +59,7 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - @ExceptionHandler(FcmException.class) + @ExceptionHandler({FcmException.class, TossPaymentException.class}) protected ErrorResponse handleFcmException(MoabamException moabamException) { return new ErrorResponse(moabamException.getMessage(), null); } diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index 039563e2..3aa0e491 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -9,7 +9,6 @@ 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; @@ -19,13 +18,11 @@ 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.ConfirmTossPaymentResponse; import com.moabam.api.dto.payment.PaymentRequest; -import com.moabam.api.infrastructure.payment.TossPaymentService; -import com.moabam.global.error.exception.MoabamException; import com.moabam.global.error.exception.NotFoundException; @ExtendWith(MockitoExtension.class) @@ -40,88 +37,56 @@ class PaymentServiceTest { @Mock CouponService couponService; - @Mock - TossPaymentService tossPaymentService; - @Mock PaymentRepository paymentRepository; @Mock PaymentSearchRepository paymentSearchRepository; - @DisplayName("결제를 요청한다.") - @Nested - class Request { - - @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") - @Test - void 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("결제 요청 시 해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @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("결제 정보 검증 시 해당 결제 정보가 존재하지 않으면 예외가 발생한다.") + @Test + void validate_info_not_found_exception() { + // given + Long memberId = 1L; + Payment payment = payment(bugProduct()); + ConfirmPaymentRequest request = confirmPaymentRequest(); + given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); + + // when, then + assertThatThrownBy(() -> paymentService.validateInfo(memberId, request)) + .isInstanceOf(NotFoundException.class) + .hasMessage("존재하지 않는 결제 정보입니다."); } - @DisplayName("결제를 승인한다.") - @Nested - class Confirm { - - @DisplayName("해당 결제 정보가 존재하지 않으면 예외가 발생한다.") - @Test - void not_found_exception() { - // given - Long memberId = 1L; - ConfirmPaymentRequest request = confirmPaymentRequest(); - given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); - - // when, then - assertThatThrownBy(() -> paymentService.confirm(memberId, request)) - .isInstanceOf(NotFoundException.class) - .hasMessage("존재하지 않는 결제 정보입니다."); - } - - @DisplayName("쿠폰을 적용한 경우 쿠폰을 차감한 후 벌레를 충전한다.") - @Test - void use_coupon_success() { - // given - Long memberId = 1L; - Long couponWalletId = 1L; - Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); - ConfirmPaymentRequest request = confirmPaymentRequest(); - given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); - given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); - - // when - paymentService.confirm(memberId, request); - - // then - verify(couponService, times(1)).discount(couponWalletId, memberId); - verify(bugService, times(1)).charge(memberId, payment.getProduct()); - } - - @DisplayName("실패한다.") - @Test - void fail() { - // given - Long memberId = 1L; - Long couponWalletId = 1L; - Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); - ConfirmPaymentRequest request = confirmPaymentRequest(); - given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.of(payment)); - given(tossPaymentService.confirm(any())).willThrow(MoabamException.class); - - // when - paymentService.confirm(memberId, request); - - // then - assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); - } + @DisplayName("결제 승인 시 쿠폰을 적용한 경우 쿠폰을 차감한 후 벌레를 충전한다.") + @Test + void confirm_with_coupon_success() { + // given + Long memberId = 1L; + Long couponWalletId = 1L; + Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); + ConfirmTossPaymentResponse response = confirmTossPaymentResponse(); + + // when + paymentService.confirm(memberId, payment, response); + + // 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/infrastructure/payment/TossPaymentServiceTest.java b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java index e9a57d0e..10617019 100644 --- a/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java +++ b/src/test/java/com/moabam/api/infrastructure/payment/TossPaymentServiceTest.java @@ -15,7 +15,7 @@ import org.springframework.test.context.ActiveProfiles; import com.fasterxml.jackson.databind.ObjectMapper; -import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; +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; @@ -58,7 +58,7 @@ class Confirm { @Test void success() throws Exception { // given - ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + ConfirmPaymentRequest request = confirmPaymentRequest(); ConfirmTossPaymentResponse expected = confirmTossPaymentResponse(); mockWebServer.enqueue(new MockResponse() .setResponseCode(200) @@ -76,7 +76,7 @@ void success() throws Exception { @Test void exception() { // given - ConfirmTossPaymentRequest request = confirmTossPaymentRequest(); + ConfirmPaymentRequest request = confirmPaymentRequest(); String jsonString = "{\"code\":\"NOT_FOUND_PAYMENT\",\"message\":\"존재하지 않는 결제 입니다.\"}"; mockWebServer.enqueue(new MockResponse() .setResponseCode(404) diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index 1fba2dca..cca7b738 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -34,6 +34,7 @@ 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; @@ -116,7 +117,7 @@ void success() throws Exception { Payment payment = paymentRepository.save(payment(product)); payment.request(ORDER_ID); ConfirmPaymentRequest request = confirmPaymentRequest(); - given(tossPaymentService.confirm(confirmTossPaymentRequest())).willReturn(confirmTossPaymentResponse()); + given(tossPaymentService.confirm(request)).willReturn(confirmTossPaymentResponse()); given(memberService.findMember(memberId)).willReturn(member()); // expected @@ -149,5 +150,29 @@ void bad_request_body_exception(String paymentKey, String orderId, int amount) t .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()); + System.out.println(payment.getStatus()); + assertThat(payment.getPaymentKey()).isEqualTo(PAYMENT_KEY); + assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); + } } } diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java index a46e4b7b..019b72ab 100644 --- a/src/test/java/com/moabam/support/fixture/PaymentFixture.java +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -10,7 +10,6 @@ import com.moabam.api.domain.payment.PaymentStatus; import com.moabam.api.domain.product.Product; import com.moabam.api.dto.payment.ConfirmPaymentRequest; -import com.moabam.api.dto.payment.ConfirmTossPaymentRequest; import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; public final class PaymentFixture { @@ -53,14 +52,6 @@ public static ConfirmPaymentRequest confirmPaymentRequest() { .build(); } - public static ConfirmTossPaymentRequest confirmTossPaymentRequest() { - return ConfirmTossPaymentRequest.builder() - .paymentKey(PAYMENT_KEY) - .orderId(ORDER_ID) - .amount(AMOUNT) - .build(); - } - public static ConfirmTossPaymentResponse confirmTossPaymentResponse() { return ConfirmTossPaymentResponse.builder() .paymentKey(PAYMENT_KEY) From 95d0256a887ac2be158084c81ce81344a6bb3ccd Mon Sep 17 00:00:00 2001 From: kmebin Date: Thu, 30 Nov 2023 02:24:12 +0900 Subject: [PATCH 120/185] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20API=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20Basic=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=97=A4=EB=8D=94=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/infrastructure/payment/TossPaymentService.java | 2 +- .../com/moabam/api/presentation/PaymentController.java | 1 - .../com/moabam/api/presentation/PaymentControllerTest.java | 7 +++++-- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java index 6c10bd5a..c53cc079 100644 --- a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -34,7 +34,7 @@ public void init() { .baseUrl(config.baseUrl()) .defaultHeaders(headers -> { headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); - headers.setBearerAuth(Base64.getEncoder().encodeToString(config.secretKey().getBytes())); + headers.setBasicAuth(Base64.getEncoder().encodeToString(config.secretKey().getBytes())); }) .build(); } diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java index 33ea55e4..d27da27a 100644 --- a/src/main/java/com/moabam/api/presentation/PaymentController.java +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -46,7 +46,6 @@ public void confirm(@Auth AuthMember member, @Valid @RequestBody ConfirmPaymentR paymentService.confirm(member.id(), payment, response); } catch (TossPaymentException exception) { paymentService.fail(payment, request.paymentKey()); - throw exception; } } } diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index cca7b738..1c6321a8 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -29,6 +29,7 @@ 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; @@ -58,6 +59,9 @@ class PaymentControllerTest extends WithoutFilterSupporter { @Autowired PaymentRepository paymentRepository; + @Autowired + PaymentSearchRepository paymentSearchRepository; + @Autowired ProductRepository productRepository; @@ -168,9 +172,8 @@ void confirm_toss_exception() throws Exception { mockMvc.perform(post("/payments/confirm") .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isInternalServerError()) + .andExpect(status().isOk()) .andDo(print()); - System.out.println(payment.getStatus()); assertThat(payment.getPaymentKey()).isEqualTo(PAYMENT_KEY); assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); } From 49b12fbeeb63e66c7bcf16c02ebe2c112c5ac358 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 15:10:09 +0900 Subject: [PATCH 121/185] =?UTF-8?q?fix:=20ModelAttribute=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EB=B3=80=EA=B2=BD=20(#193)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/application/image/ImageService.java | 27 ++++++++++++++----- .../moabam/api/domain/image/ImageResizer.java | 4 +-- .../{ResizedImage.java => NewImage.java} | 6 ++--- .../api/dto/room/CertifyRoomRequest.java | 13 +++++++++ .../api/dto/room/CertifyRoomsRequest.java | 12 +++++++++ .../api/presentation/RoomController.java | 21 +++++++-------- .../application/image/ImageServiceTest.java | 4 +-- 7 files changed, 63 insertions(+), 24 deletions(-) rename src/main/java/com/moabam/api/domain/image/{ResizedImage.java => NewImage.java} (85%) create mode 100644 src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java create mode 100644 src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java diff --git a/src/main/java/com/moabam/api/application/image/ImageService.java b/src/main/java/com/moabam/api/application/image/ImageService.java index c2b4208f..adabbf27 100644 --- a/src/main/java/com/moabam/api/application/image/ImageService.java +++ b/src/main/java/com/moabam/api/application/image/ImageService.java @@ -1,5 +1,6 @@ package com.moabam.api.application.image; +import java.io.IOException; import java.util.ArrayList; import java.util.List; @@ -10,6 +11,8 @@ 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 lombok.RequiredArgsConstructor; @@ -22,7 +25,7 @@ public class ImageService { private final S3Manager s3Manager; @Transactional - public List uploadImages(List multipartFiles, ImageType imageType) { + public List uploadImages(List multipartFiles, ImageType imageType) { List result = new ArrayList<>(); @@ -38,6 +41,23 @@ public List uploadImages(List multipartFiles, ImageType i return result; } + public List getNewImages(CertifyRoomsRequest request) { + return request.certifyRoomsRequest().stream() + .map(c -> { + try { + return NewImage.of(c.routineId().toString(), c.image().getContentType(), c.image().getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + }) + .toList(); + } + + @Transactional + public void deleteImage(String imageUrl) { + s3Manager.deleteImage(imageUrl); + } + private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType imageType) { ImageName imageName = ImageName.of(multipartFile, imageType); @@ -46,9 +66,4 @@ private ImageResizer toImageResizer(MultipartFile multipartFile, ImageType image .fileName(imageName.getFileName()) .build(); } - - @Transactional - public void deleteImage(String imageUrl) { - s3Manager.deleteImage(imageUrl); - } } diff --git a/src/main/java/com/moabam/api/domain/image/ImageResizer.java b/src/main/java/com/moabam/api/domain/image/ImageResizer.java index 5a7d65ba..82b2f1d4 100644 --- a/src/main/java/com/moabam/api/domain/image/ImageResizer.java +++ b/src/main/java/com/moabam/api/domain/image/ImageResizer.java @@ -116,7 +116,7 @@ private BufferedImage getBufferedImage() { } } - private ResizedImage toMultipartFile(byte[] bytes) { - return ResizedImage.of(fileName, image.getContentType(), bytes); + private NewImage toMultipartFile(byte[] bytes) { + return NewImage.of(fileName, image.getContentType(), bytes); } } diff --git a/src/main/java/com/moabam/api/domain/image/ResizedImage.java b/src/main/java/com/moabam/api/domain/image/NewImage.java similarity index 85% rename from src/main/java/com/moabam/api/domain/image/ResizedImage.java rename to src/main/java/com/moabam/api/domain/image/NewImage.java index d7568527..6367d63c 100644 --- a/src/main/java/com/moabam/api/domain/image/ResizedImage.java +++ b/src/main/java/com/moabam/api/domain/image/NewImage.java @@ -12,15 +12,15 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor(access = AccessLevel.PRIVATE) -public class ResizedImage implements MultipartFile { +public class NewImage implements MultipartFile { private final String name; private final String contentType; private final long size; private final byte[] bytes; - public static ResizedImage of(String name, String contentType, byte[] bytes) { - return new ResizedImage(name, contentType, bytes.length, bytes); + public static NewImage of(String name, String contentType, byte[] bytes) { + return new NewImage(name, contentType, bytes.length, bytes); } @Override 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..7ef3496a --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java @@ -0,0 +1,13 @@ +package com.moabam.api.dto.room; + +import org.springframework.web.multipart.MultipartFile; + +import lombok.Builder; + +@Builder +public record CertifyRoomRequest( + Long routineId, + 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..6c6338e1 --- /dev/null +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java @@ -0,0 +1,12 @@ +package com.moabam.api.dto.room; + +import java.util.List; + +import lombok.Builder; + +@Builder +public record CertifyRoomsRequest( + List certifyRoomsRequest +) { + +} diff --git a/src/main/java/com/moabam/api/presentation/RoomController.java b/src/main/java/com/moabam/api/presentation/RoomController.java index 66bb4bed..ffc3c52b 100644 --- a/src/main/java/com/moabam/api/presentation/RoomController.java +++ b/src/main/java/com/moabam/api/presentation/RoomController.java @@ -1,7 +1,7 @@ package com.moabam.api.presentation; import java.time.LocalDate; -import java.util.Map; +import java.util.List; import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; @@ -12,16 +12,18 @@ 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.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.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; @@ -110,14 +112,11 @@ public RoomDetailsResponse getRoomDetails(@Auth AuthMember authMember, @PathVari @PostMapping("/{roomId}/certification") @ResponseStatus(HttpStatus.CREATED) public void certifyRoom(@Auth AuthMember authMember, @PathVariable("roomId") Long roomId, - @RequestPart(name = "file") Map multipartFiles) { - - log.info("multipartFiles Size = {}", multipartFiles.size()); - log.info(multipartFiles.toString()); - - // List imageUrls = imageService.uploadImages(multipartFiles, ImageType.CERTIFICATION); - // CertifiedMemberInfo info = certificationService.getCertifiedMemberInfo(authMember.id(), roomId, imageUrls); - // certificationService.certifyRoom(info); + 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") diff --git a/src/test/java/com/moabam/api/application/image/ImageServiceTest.java b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java index d8cdcc79..ea2e67ed 100644 --- a/src/test/java/com/moabam/api/application/image/ImageServiceTest.java +++ b/src/test/java/com/moabam/api/application/image/ImageServiceTest.java @@ -16,7 +16,7 @@ import org.springframework.web.multipart.MultipartFile; import com.moabam.api.domain.image.ImageType; -import com.moabam.api.domain.image.ResizedImage; +import com.moabam.api.domain.image.NewImage; import com.moabam.api.infrastructure.s3.S3Manager; import com.moabam.support.fixture.RoomFixture; @@ -38,7 +38,7 @@ void image_resize_upload_success() { MockMultipartFile image1 = RoomFixture.makeMultipartFile1(); List images = List.of(image1); - given(s3Manager.uploadImage(anyString(), any(ResizedImage.class))).willReturn(image1.getName()); + given(s3Manager.uploadImage(anyString(), any(NewImage.class))).willReturn(image1.getName()); // when List result = imageService.uploadImages(images, imageType); From 5f07bebf44dbd449b6d4494fb9b1c0ba734350ae Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 30 Nov 2023 15:42:28 +0900 Subject: [PATCH 122/185] =?UTF-8?q?fix:=20=ED=86=A0=EC=8A=A4=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EC=8A=B9=EC=9D=B8=20=EC=84=B1=EA=B3=B5/=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=8B=9C=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=20=EC=95=88=EB=90=98=EB=8A=94=20=EC=9D=B4=EC=8A=88=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=20(#194)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 결제 정보 검증 및 토스 결제 승인 API 로직 트랜잭션 분리 * test: 로직 변경에 따른 테스트 수정 --- .../application/payment/PaymentMapper.java | 10 +++ .../application/payment/PaymentService.java | 21 ++++--- .../RequestConfirmPaymentResponse.java | 13 ++++ .../payment/TossPaymentService.java | 3 - .../api/presentation/PaymentController.java | 16 +---- .../payment/PaymentServiceTest.java | 62 +++++++++++++------ .../presentation/PaymentControllerTest.java | 4 +- 7 files changed, 83 insertions(+), 46 deletions(-) create mode 100644 src/main/java/com/moabam/api/dto/payment/RequestConfirmPaymentResponse.java diff --git a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java index 322ce92a..e98b64eb 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentMapper.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentMapper.java @@ -5,7 +5,9 @@ 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; @@ -36,4 +38,12 @@ public static PaymentResponse toPaymentResponse(Payment payment) { .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 index 710e37b0..2d6137e3 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -13,8 +13,10 @@ 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; @@ -36,16 +38,22 @@ public void request(Long memberId, Long paymentId, PaymentRequest request) { payment.request(request.orderId()); } - public Payment validateInfo(Long memberId, ConfirmPaymentRequest request) { + public RequestConfirmPaymentResponse requestConfirm(Long memberId, ConfirmPaymentRequest request) { Payment payment = getByOrderId(request.orderId()); payment.validateInfo(memberId, request.amount()); - return payment; + 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, ConfirmTossPaymentResponse response) { - payment.confirm(response.paymentKey()); + public void confirm(Long memberId, Payment payment, String paymentKey) { + payment.confirm(paymentKey); if (payment.isCouponApplied()) { couponService.discount(payment.getCouponWalletId(), memberId); @@ -53,11 +61,6 @@ public void confirm(Long memberId, Payment payment, ConfirmTossPaymentResponse r bugService.charge(memberId, payment.getProduct()); } - @Transactional - public void fail(Payment payment, String paymentKey) { - payment.fail(paymentKey); - } - private Payment getById(Long paymentId) { return paymentRepository.findById(paymentId) .orElseThrow(() -> new NotFoundException(PAYMENT_NOT_FOUND)); 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/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java index c53cc079..476cfe24 100644 --- a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -6,7 +6,6 @@ import org.springframework.http.HttpStatusCode; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; @@ -21,7 +20,6 @@ import reactor.core.publisher.Mono; @Service -@Transactional(readOnly = true) @RequiredArgsConstructor public class TossPaymentService { @@ -39,7 +37,6 @@ public void init() { .build(); } - @Transactional public ConfirmTossPaymentResponse confirm(ConfirmPaymentRequest request) { return webClient.post() .uri("/v1/payments/confirm") diff --git a/src/main/java/com/moabam/api/presentation/PaymentController.java b/src/main/java/com/moabam/api/presentation/PaymentController.java index d27da27a..ab613b73 100644 --- a/src/main/java/com/moabam/api/presentation/PaymentController.java +++ b/src/main/java/com/moabam/api/presentation/PaymentController.java @@ -9,14 +9,11 @@ import org.springframework.web.bind.annotation.RestController; import com.moabam.api.application.payment.PaymentService; -import com.moabam.api.domain.payment.Payment; 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.infrastructure.payment.TossPaymentService; +import com.moabam.api.dto.payment.RequestConfirmPaymentResponse; import com.moabam.global.auth.annotation.Auth; import com.moabam.global.auth.model.AuthMember; -import com.moabam.global.error.exception.TossPaymentException; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -27,7 +24,6 @@ public class PaymentController { private final PaymentService paymentService; - private final TossPaymentService tossPaymentService; @PostMapping("/{paymentId}") @ResponseStatus(HttpStatus.OK) @@ -39,13 +35,7 @@ public void request(@Auth AuthMember member, @PathVariable Long paymentId, @PostMapping("/confirm") @ResponseStatus(HttpStatus.OK) public void confirm(@Auth AuthMember member, @Valid @RequestBody ConfirmPaymentRequest request) { - Payment payment = paymentService.validateInfo(member.id(), request); - - try { - ConfirmTossPaymentResponse response = tossPaymentService.confirm(request); - paymentService.confirm(member.id(), payment, response); - } catch (TossPaymentException exception) { - paymentService.fail(payment, request.paymentKey()); - } + RequestConfirmPaymentResponse response = paymentService.requestConfirm(member.id(), request); + paymentService.confirm(member.id(), response.payment(), response.paymentKey()); } } diff --git a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java index 3aa0e491..4158682c 100644 --- a/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java +++ b/src/test/java/com/moabam/api/application/payment/PaymentServiceTest.java @@ -9,6 +9,7 @@ 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; @@ -18,12 +19,14 @@ 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.ConfirmTossPaymentResponse; 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 { @@ -37,6 +40,9 @@ class PaymentServiceTest { @Mock CouponService couponService; + @Mock + TossPaymentService tossPaymentService; + @Mock PaymentRepository paymentRepository; @@ -58,32 +64,52 @@ void request_not_found_exception() { .hasMessage("존재하지 않는 결제 정보입니다."); } - @DisplayName("결제 정보 검증 시 해당 결제 정보가 존재하지 않으면 예외가 발생한다.") - @Test - void validate_info_not_found_exception() { - // given - Long memberId = 1L; - Payment payment = payment(bugProduct()); - ConfirmPaymentRequest request = confirmPaymentRequest(); - given(paymentSearchRepository.findByOrderId(request.orderId())).willReturn(Optional.empty()); - - // when, then - assertThatThrownBy(() -> paymentService.validateInfo(memberId, 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("결제 승인 시 쿠폰을 적용한 경우 쿠폰을 차감한 후 벌레를 충전한다.") + @DisplayName("결제 승인에 성공한다.") @Test - void confirm_with_coupon_success() { + void confirm_success() { // given Long memberId = 1L; Long couponWalletId = 1L; Payment payment = paymentWithCoupon(bugProduct(), discount1000Coupon(), couponWalletId); - ConfirmTossPaymentResponse response = confirmTossPaymentResponse(); // when - paymentService.confirm(memberId, payment, response); + paymentService.confirm(memberId, payment, PAYMENT_KEY); // then verify(couponService, times(1)).discount(couponWalletId, memberId); diff --git a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java index 1c6321a8..7bbd2b6c 100644 --- a/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/PaymentControllerTest.java @@ -172,10 +172,8 @@ void confirm_toss_exception() throws Exception { mockMvc.perform(post("/payments/confirm") .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))) - .andExpect(status().isOk()) + .andExpect(status().isInternalServerError()) .andDo(print()); - assertThat(payment.getPaymentKey()).isEqualTo(PAYMENT_KEY); - assertThat(payment.getStatus()).isEqualTo(PaymentStatus.ABORTED); } } } From 70de15bda5ea8d2e15c552326f0584d6fe1f7068 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 30 Nov 2023 16:21:47 +0900 Subject: [PATCH 123/185] =?UTF-8?q?feat:=20ranking=20system=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 --------- Co-authored-by: Kim Heebin --- .../api/application/member/MemberMapper.java | 17 ++ .../api/application/member/MemberService.java | 32 ++- .../application/ranking/RankingMapper.java | 43 ++++ .../application/ranking/RankingService.java | 76 ++++++ .../repository/MemberSearchRepository.java | 9 + .../moabam/api/dto/ranking/RankingInfo.java | 12 + .../api/dto/ranking/TopRankingInfo.java | 14 ++ .../api/dto/ranking/TopRankingResponse.java | 13 + .../moabam/api/dto/ranking/UpdateRanking.java | 11 + .../redis/ZSetRedisRepository.java | 40 +++ .../api/presentation/RankingController.java | 33 +++ .../global/config/EmbeddedRedisConfig.java | 10 + .../com/moabam/global/config/RedisConfig.java | 11 + .../application/member/MemberServiceTest.java | 17 ++ .../ranking/RankingServiceTest.java | 228 ++++++++++++++++++ .../CertificationServiceConcurrencyTest.java | 10 +- .../room/CertificationServiceTest.java | 6 +- .../room/RoomServiceConcurrencyTest.java | 8 +- .../api/application/room/RoomServiceTest.java | 2 +- .../InventorySearchRepositoryTest.java | 10 +- .../domain/member/MemberRepositoryTest.java | 10 +- .../presentation/MemberControllerTest.java | 27 ++- .../NotificationControllerTest.java | 2 +- .../presentation/RankingControllerTest.java | 82 +++++++ .../presentation/ReportControllerTest.java | 4 +- .../api/presentation/RoomControllerTest.java | 14 +- .../moabam/support/fixture/MemberFixture.java | 2 +- .../support/fixture/RankingInfoFixture.java | 5 + 28 files changed, 710 insertions(+), 38 deletions(-) create mode 100644 src/main/java/com/moabam/api/application/ranking/RankingMapper.java create mode 100644 src/main/java/com/moabam/api/application/ranking/RankingService.java create mode 100644 src/main/java/com/moabam/api/dto/ranking/RankingInfo.java create mode 100644 src/main/java/com/moabam/api/dto/ranking/TopRankingInfo.java create mode 100644 src/main/java/com/moabam/api/dto/ranking/TopRankingResponse.java create mode 100644 src/main/java/com/moabam/api/dto/ranking/UpdateRanking.java create mode 100644 src/main/java/com/moabam/api/presentation/RankingController.java create mode 100644 src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java create mode 100644 src/test/java/com/moabam/api/presentation/RankingControllerTest.java create mode 100644 src/test/java/com/moabam/support/fixture/RankingInfoFixture.java diff --git a/src/main/java/com/moabam/api/application/member/MemberMapper.java b/src/main/java/com/moabam/api/application/member/MemberMapper.java index 148a0b8d..67fd8a92 100644 --- a/src/main/java/com/moabam/api/application/member/MemberMapper.java +++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java @@ -19,6 +19,8 @@ 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; @@ -33,6 +35,13 @@ public static Member toMember(Long socialId) { .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() @@ -79,6 +88,14 @@ public static Inventory toInventory(Long memberId, Item item) { .build(); } + public static RankingInfo toRankingInfo(Member member) { + return RankingInfo.builder() + .memberId(member.getId()) + .nickname(member.getNickname()) + .image(member.getProfileImage()) + .build(); + } + private static List badgedNames(Set badgeTypes) { return BadgeType.memberBadgeMap(badgeTypes); } diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 8ed67cc5..358cf2df 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -6,10 +6,12 @@ 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; @@ -26,6 +28,8 @@ 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; @@ -42,6 +46,7 @@ @RequiredArgsConstructor public class MemberService { + private final RankingService rankingService; private final FcmService fcmService; private final MemberRepository memberRepository; private final InventoryRepository inventoryRepository; @@ -85,6 +90,7 @@ public void delete(Member member) { member.delete(clockHolder.times()); memberRepository.flush(); memberRepository.delete(member); + rankingService.removeRanking(MemberMapper.toRankingInfo(member)); fcmService.deleteTokenByMemberId(member.getId()); } @@ -96,27 +102,48 @@ public MemberInfoResponse searchInfo(AuthMember authMember, Long memberId) { 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 15 * * * *") + 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); @@ -138,6 +165,7 @@ 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; } 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..06ad3389 --- /dev/null +++ b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java @@ -0,0 +1,43 @@ +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) + .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..aa48370d --- /dev/null +++ b/src/main/java/com/moabam/api/application/ranking/RankingService.java @@ -0,0 +1,76 @@ +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.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()); + 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/domain/member/repository/MemberSearchRepository.java b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java index b853b871..82a43a01 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -29,6 +29,15 @@ public Optional findMember(Long memberId) { return findMember(memberId, true); } + public List findAllMembers() { + return jpaQueryFactory + .selectFrom(member) + .where( + member.deletedAt.isNotNull() + ) + .fetch(); + } + public Optional findMember(Long memberId, boolean isNotDeleted) { return Optional.ofNullable(jpaQueryFactory .selectFrom(member) 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/infrastructure/redis/ZSetRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java index e116d764..d68e43b7 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -6,6 +6,9 @@ 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; @@ -35,4 +38,41 @@ public Long rank(String key, Object value) { .opsForZSet() .rank(key, value); } + + 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/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/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index 32ad60ec..a823fd26 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -17,6 +17,8 @@ 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; @@ -62,6 +64,14 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModules(new JavaTimeModule()); + + return objectMapper; + } + public void startRedis() { Os os = Os.createOs(); availablePort = findPort(os); diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index 9d017cce..cc412271 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -10,6 +10,9 @@ import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + @Configuration @Profile("!test") public class RedisConfig { @@ -35,4 +38,12 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModules(new JavaTimeModule()); + + return objectMapper; + } } diff --git a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java index bdf62154..5bb68977 100644 --- a/src/test/java/com/moabam/api/application/member/MemberServiceTest.java +++ b/src/test/java/com/moabam/api/application/member/MemberServiceTest.java @@ -16,6 +16,7 @@ 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; @@ -69,6 +70,9 @@ class MemberServiceTest { @Mock InventoryRepository inventoryRepository; + @Mock + RankingService rankingService; + @Mock FcmService fcmService; @@ -229,4 +233,17 @@ void modify_success_test(@WithMember AuthMember authMember) { () -> 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/ranking/RankingServiceTest.java b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java new file mode 100644 index 00000000..d246c5df --- /dev/null +++ b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java @@ -0,0 +1,228 @@ +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.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; + + @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/room/CertificationServiceConcurrencyTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java index fd1fdc1e..3edc5543 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceConcurrencyTest.java @@ -61,11 +61,11 @@ void certify_room_success() throws InterruptedException { } Room savedRoom = roomRepository.save(room); - Member member1 = MemberFixture.member("0000", "닉네임1"); - Member member2 = MemberFixture.member("1234", "닉네임2"); - Member member3 = MemberFixture.member("5678", "닉네임3"); - Member member4 = MemberFixture.member("3333", "닉네임4"); - Member member5 = MemberFixture.member("5555", "닉네임5"); + 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)); diff --git a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java index d15ecdf1..32b9eb9b 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -102,9 +102,9 @@ class CertificationServiceTest { void init() { room = spy(RoomFixture.room()); participant = spy(RoomFixture.participant(room, 1L)); - member1 = MemberFixture.member("1", "회원1"); - member2 = MemberFixture.member("2", "회원2"); - member3 = MemberFixture.member("3", "회원3"); + member1 = MemberFixture.member("1"); + member2 = MemberFixture.member("2"); + member3 = MemberFixture.member("3"); lenient().when(room.getId()).thenReturn(1L); lenient().when(participant.getRoom()).thenReturn(room); diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java index 53a190f9..7e1c0f41 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java @@ -60,9 +60,9 @@ void enter_room_concurrency_test() throws InterruptedException { Room savedRoom = roomRepository.save(room); - Member member1 = MemberFixture.member("qwe", "닉네임1"); - Member member2 = MemberFixture.member("qwfe", "닉네임2"); - Member member3 = MemberFixture.member("qff", "닉네임3"); + 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()); @@ -79,7 +79,7 @@ void enter_room_concurrency_test() throws InterruptedException { // when for (int i = 0; i < threadCount; i++) { - Member member = MemberFixture.member(String.valueOf(i + 100), "test"); + Member member = MemberFixture.member(String.valueOf(i + 100)); newMembers.add(member); memberRepository.save(member); final Long memberId = member.getId(); diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java index 667508f0..2a37acfc 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceTest.java @@ -115,7 +115,7 @@ void room_manager_mandate_success() { Long managerId = 1L; Long memberId = 2L; - Member member = MemberFixture.member("1234", "닉네임"); + Member member = MemberFixture.member("1234"); Room room = spy(RoomFixture.room()); given(room.getId()).willReturn(1L); 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 index 649a73e7..b609e309 100644 --- a/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/item/repository/InventorySearchRepositoryTest.java @@ -85,7 +85,7 @@ void empty_success() { @Test void find_one_success() { // given - Member member = memberRepository.save(member("999", "test")); + Member member = memberRepository.save(member("999")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); @@ -100,7 +100,7 @@ void find_one_success() { @Test void find_default_success() { // given - Member member = memberRepository.save(member("11314", "test")); + Member member = memberRepository.save(member("11314")); Item item = itemRepository.save(nightMageSkin()); Inventory inventory = inventoryRepository.save(inventory(member.getId(), item)); inventory.select(member); @@ -116,8 +116,8 @@ void find_default_success() { @Test void find_all_default_type_night_success() { // given - Member member1 = memberRepository.save(member("625", "회원1")); - Member member2 = memberRepository.save(member("255", "회원2")); + 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)); @@ -141,7 +141,7 @@ class FindDefaultBird { @Test void bird_find_success() { // given - Member member = MemberFixture.member("fffdd", "test"); + Member member = MemberFixture.member("fffdd"); member.exitRoom(RoomType.MORNING); memberRepository.save(member); diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index 8f600444..90729351 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -58,7 +58,7 @@ class MemberRepositoryTest { @Test void test() { // given - Member member = MemberFixture.member("313", "test"); + Member member = MemberFixture.member("313"); memberRepository.save(member); // when @@ -76,7 +76,7 @@ class FindMemberTest { @Test void room_exist_and_manager_error() { // given - Member member = MemberFixture.member("1111", "test"); + Member member = MemberFixture.member("1111"); memberRepository.save(member); Room room = RoomFixture.room(); @@ -102,7 +102,7 @@ void room_exist_and_not_manager_success() { room.changeManagerNickname("test"); roomRepository.save(room); - Member member = MemberFixture.member("44", "test"); + Member member = MemberFixture.member("44"); member.changeNickName("not"); memberRepository.save(member); @@ -133,7 +133,7 @@ void member_not_found() { @Test void search_info_success() { // given - Member member = MemberFixture.member("hhhh", "test"); + Member member = MemberFixture.member("hhhh"); member.enterRoom(RoomType.MORNING); memberRepository.save(member); @@ -158,7 +158,7 @@ void search_info_success() { @Test void no_badges_search_success() { // given - Member member = MemberFixture.member("ttttt", "test"); + Member member = MemberFixture.member("ttttt"); member.enterRoom(RoomType.MORNING); memberRepository.save(member); diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index f9ce684a..6297567c 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -12,6 +12,7 @@ import java.nio.charset.StandardCharsets; import java.util.List; +import java.util.Objects; import java.util.Optional; import org.assertj.core.api.Assertions; @@ -29,6 +30,8 @@ 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; @@ -45,6 +48,7 @@ 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; @@ -63,6 +67,8 @@ 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; @@ -83,6 +89,7 @@ @AutoConfigureMockMvc @AutoConfigureMockRestServiceServer @TestInstance(TestInstance.Lifecycle.PER_CLASS) +@Import(EmbeddedRedisConfig.class) class MemberControllerTest extends WithoutFilterSupporter { @Autowired @@ -133,12 +140,15 @@ class MemberControllerTest extends WithoutFilterSupporter { @Autowired EntityManager entityManager; + @Autowired + RedisTemplate redisTemplate; + @BeforeAll void allSetUp() { restTemplateBuilder = new RestTemplateBuilder() .errorHandler(new RestTemplateResponseHandler()); - member = MemberFixture.member("1234567890987654", "nickname"); + member = MemberFixture.member("1234567890987654"); member.increaseTotalCertifyCount(); memberRepository.save(member); } @@ -361,7 +371,7 @@ void search_my_info_with_no_badge_success() throws Exception { @Test void search_friend_info_success() throws Exception { // given - Member friend = MemberFixture.member("123456789", "nick"); + Member friend = MemberFixture.member("123456789"); memberRepository.save(friend); Badge morningBirth = BadgeFixture.badge(friend.getId(), BadgeType.MORNING_BIRTH); @@ -481,6 +491,7 @@ void member_modify_request_success(String intro, String nickname) throws Excepti .characterEncoding("UTF-8")) .andExpect(status().is2xxSuccessful()) .andDo(print()); + } @DisplayName("회원 프로필없이 성공 ") @@ -499,6 +510,8 @@ void member_modify_no_image_request_success(String intro, String nickname) throw 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") @@ -508,5 +521,15 @@ void member_modify_no_image_request_success(String intro, String nickname) throw .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 index 3df0a842..400ce3ee 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -73,7 +73,7 @@ class NotificationControllerTest extends WithoutFilterSupporter { @BeforeEach void setUp() { - target = memberRepository.save(MemberFixture.member("123", "targetName")); + target = memberRepository.save(MemberFixture.member("123")); room = roomRepository.save(RoomFixture.room()); knockKey = String.format("room_%s_member_%s_knocks_%s", room.getId(), 1, target.getId()); 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..9e124cb2 --- /dev/null +++ b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java @@ -0,0 +1,82 @@ +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.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; + + @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(21))); + + // then + + } + +} diff --git a/src/test/java/com/moabam/api/presentation/ReportControllerTest.java b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java index 006eb10a..4f5c688d 100644 --- a/src/test/java/com/moabam/api/presentation/ReportControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/ReportControllerTest.java @@ -110,7 +110,7 @@ void reports_success(boolean roomFilter, boolean certificationFilter) throws Exc @Test void reports_failBy_subject_null() throws Exception { // given - Member member = MemberFixture.member("2", "ji"); + Member member = MemberFixture.member("2"); memberRepository.save(member); ReportRequest reportRequest = ReportFixture.reportRequest(member.getId(), null, null); @@ -128,7 +128,7 @@ void reports_failBy_subject_null() throws Exception { @Test void reports_failBy_member() throws Exception { // given - Member newMember = MemberFixture.member("9999", "n"); + Member newMember = MemberFixture.member("9999"); memberRepository.save(newMember); newMember.delete(LocalDateTime.now()); diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index c922bc93..a1c850e7 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -799,8 +799,8 @@ void get_room_details_test() throws Exception { Participant participant1 = RoomFixture.participant(room, 1L); participant1.enableManager(); - Member member2 = MemberFixture.member("2", "NICK2"); - Member member3 = MemberFixture.member("3", "NICK3"); + Member member2 = MemberFixture.member("2"); + Member member3 = MemberFixture.member("3"); roomRepository.save(room); routineRepository.saveAll(routines); @@ -865,7 +865,7 @@ void get_room_details_test() throws Exception { void deport_member_success() throws Exception { // given Room room = RoomFixture.room(); - Member member = MemberFixture.member("1234", "참여자"); + Member member = MemberFixture.member("1234"); memberRepository.save(member); Participant memberParticipant = RoomFixture.participant(room, member.getId()); @@ -916,7 +916,7 @@ void deport_self_fail() throws Exception { @Test void mandate_manager_success() throws Exception { // given - Member member2 = MemberFixture.member("1234", "방장될 멤버"); + Member member2 = MemberFixture.member("1234"); memberRepository.save(member2); Room room = RoomFixture.room(); @@ -1016,7 +1016,7 @@ void get_un_joined_room_details() throws Exception { Room room = RoomFixture.room("테스트 방", NIGHT, 21); Room savedRoom = roomRepository.save(room); - Member member1 = MemberFixture.member("901010", "testtest"); + Member member1 = MemberFixture.member("901010"); member1 = memberRepository.save(member1); Item item = ItemFixture.nightMageSkin(); @@ -1536,8 +1536,8 @@ void search_first_page_all_rooms_by_keyword_roomType_roomId_success() throws Exc @Test void get_room_details_before_modification_success() throws Exception { // given - Member member2 = MemberFixture.member("123", "참여자1"); - Member member3 = MemberFixture.member("456", "참여자2"); + Member member2 = MemberFixture.member("123"); + Member member3 = MemberFixture.member("456"); member2 = memberRepository.save(member2); member3 = memberRepository.save(member3); diff --git a/src/test/java/com/moabam/support/fixture/MemberFixture.java b/src/test/java/com/moabam/support/fixture/MemberFixture.java index 3bf73d15..c0ed7551 100644 --- a/src/test/java/com/moabam/support/fixture/MemberFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberFixture.java @@ -30,7 +30,7 @@ public static Member member(Bug bug) { .build(); } - public static Member member(String socialId, String nickname) { + public static Member member(String socialId) { return Member.builder() .socialId(socialId) .bug(BugFixture.bug()) 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 { + +} From 49acb0b5b99acfbd5d45b20d6c3a867ef5090d50 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 16:29:17 +0900 Subject: [PATCH 124/185] =?UTF-8?q?fix:=20record=EB=A5=BC=20class=EB=A1=9C?= =?UTF-8?q?=20=EB=B0=94=EA=BF=94=EC=84=9C=20=EB=B0=94=EC=9D=B8=EB=94=A9=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(#195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: ModelAttribute로 방식 변경 * fix: record를 class로 바꾸고 바인딩 해결 --- .../moabam/api/application/image/ImageService.java | 12 ++++++++---- .../com/moabam/api/dto/room/CertifyRoomRequest.java | 13 +++++++------ .../moabam/api/dto/room/CertifyRoomsRequest.java | 11 ++++++----- .../com/moabam/global/error/model/ErrorMessage.java | 1 + 4 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/moabam/api/application/image/ImageService.java b/src/main/java/com/moabam/api/application/image/ImageService.java index adabbf27..a70a783f 100644 --- a/src/main/java/com/moabam/api/application/image/ImageService.java +++ b/src/main/java/com/moabam/api/application/image/ImageService.java @@ -1,5 +1,7 @@ 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; @@ -14,6 +16,7 @@ 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; @@ -42,12 +45,13 @@ public List uploadImages(List multipartFiles, I } public List getNewImages(CertifyRoomsRequest request) { - return request.certifyRoomsRequest().stream() - .map(c -> { + return request.getCertifyRoomsRequest().stream() + .map(certifyRoomRequest -> { try { - return NewImage.of(c.routineId().toString(), c.image().getContentType(), c.image().getBytes()); + return NewImage.of(String.valueOf(certifyRoomRequest.getRoutineId()), + certifyRoomRequest.getImage().getContentType(), certifyRoomRequest.getImage().getBytes()); } catch (IOException e) { - throw new RuntimeException(e); + throw new BadRequestException(IMAGE_CONVERT_FAIL); } }) .toList(); diff --git a/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java b/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java index 7ef3496a..594942e4 100644 --- a/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomRequest.java @@ -2,12 +2,13 @@ import org.springframework.web.multipart.MultipartFile; -import lombok.Builder; +import lombok.Getter; +import lombok.Setter; -@Builder -public record CertifyRoomRequest( - Long routineId, - MultipartFile image -) { +@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 index 6c6338e1..c2cf4110 100644 --- a/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java +++ b/src/main/java/com/moabam/api/dto/room/CertifyRoomsRequest.java @@ -2,11 +2,12 @@ import java.util.List; -import lombok.Builder; +import lombok.Getter; +import lombok.Setter; -@Builder -public record CertifyRoomsRequest( - List certifyRoomsRequest -) { +@Getter +@Setter +public class CertifyRoomsRequest { + private List certifyRoomsRequest; } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 8a16d99b..cd39ce68 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -31,6 +31,7 @@ public enum ErrorMessage { CERTIFICATION_NOT_FOUND("인증 정보가 없습니다."), NEED_TO_EXIT_ALL_ROOMS("모든 방에서 나가야 회원 탈퇴가 가능합니다."), PARTICIPANT_DEPORT_ERROR("방장은 자신을 추방할 수 없습니다."), + IMAGE_CONVERT_FAIL("이미지 변환을 실패했습니다."), LOGIN_FAILED("로그인에 실패했습니다."), REQUEST_FAILED("네트워크 접근 실패입니다."), From fb8eb2309aea032f47997ded09d4962edcd1c248 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 30 Nov 2023 16:38:58 +0900 Subject: [PATCH 125/185] =?UTF-8?q?fix:=20approvedAt=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20(#197)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moabam/api/dto/payment/ConfirmTossPaymentResponse.java | 7 +------ .../java/com/moabam/support/fixture/PaymentFixture.java | 5 ----- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java index ee32917a..34b5bdb7 100644 --- a/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java +++ b/src/main/java/com/moabam/api/dto/payment/ConfirmTossPaymentResponse.java @@ -1,9 +1,6 @@ package com.moabam.api.dto.payment; -import java.time.LocalDateTime; - import com.fasterxml.jackson.annotation.JsonIgnoreProperties; -import com.moabam.api.domain.payment.PaymentStatus; import lombok.Builder; @@ -13,9 +10,7 @@ public record ConfirmTossPaymentResponse( String paymentKey, String orderId, String orderName, - PaymentStatus status, - int totalAmount, - LocalDateTime approvedAt + int totalAmount ) { } diff --git a/src/test/java/com/moabam/support/fixture/PaymentFixture.java b/src/test/java/com/moabam/support/fixture/PaymentFixture.java index 019b72ab..464652fb 100644 --- a/src/test/java/com/moabam/support/fixture/PaymentFixture.java +++ b/src/test/java/com/moabam/support/fixture/PaymentFixture.java @@ -2,12 +2,9 @@ import static com.moabam.support.fixture.ProductFixture.*; -import java.time.LocalDateTime; - 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.payment.PaymentStatus; import com.moabam.api.domain.product.Product; import com.moabam.api.dto.payment.ConfirmPaymentRequest; import com.moabam.api.dto.payment.ConfirmTossPaymentResponse; @@ -57,9 +54,7 @@ public static ConfirmTossPaymentResponse confirmTossPaymentResponse() { .paymentKey(PAYMENT_KEY) .orderId(ORDER_ID) .orderName(BUG_PRODUCT_NAME) - .status(PaymentStatus.DONE) .totalAmount(AMOUNT) - .approvedAt(LocalDateTime.of(2023, 1, 1, 1, 1)) .build(); } } From e8cac538c2b05569a4c0e38efa3cb62949619a86 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Thu, 30 Nov 2023 17:05:10 +0900 Subject: [PATCH 126/185] =?UTF-8?q?fix:=20=EB=B2=8C=EB=A0=88=200=EB=A7=88?= =?UTF-8?q?=EB=A6=AC=EC=9D=B8=20=EA=B2=BD=EC=9A=B0=20=EB=82=B4=EC=97=AD=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#199)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/moabam/api/application/bug/BugService.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/main/java/com/moabam/api/application/bug/BugService.java b/src/main/java/com/moabam/api/application/bug/BugService.java index f06401e8..f1246d45 100644 --- a/src/main/java/com/moabam/api/application/bug/BugService.java +++ b/src/main/java/com/moabam/api/application/bug/BugService.java @@ -80,6 +80,10 @@ public PurchaseProductResponse purchaseBugProduct(Long memberId, Long productId, @Transactional public void use(Member member, BugType bugType, int count) { + if (count == 0) { + return; + } + Bug bug = member.getBug(); bug.use(bugType, count); @@ -88,6 +92,10 @@ public void use(Member member, BugType bugType, int count) { @Transactional public void reward(Member member, BugType bugType, int count) { + if (count == 0) { + return; + } + Bug bug = member.getBug(); bug.increase(bugType, count); @@ -104,6 +112,10 @@ public void charge(Long memberId, Product bugProduct) { @Transactional public void applyCoupon(Long memberId, BugType bugType, int count) { + if (count == 0) { + return; + } + Bug bug = getByMemberId(memberId); bug.increase(bugType, count); From a2801599bddbdb5cf28ef7a9c7f98bb9f5a3496d Mon Sep 17 00:00:00 2001 From: kmebin Date: Thu, 30 Nov 2023 17:23:05 +0900 Subject: [PATCH 127/185] =?UTF-8?q?chore:=20=EA=B2=B0=EC=A0=9C=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=EC=97=90=20?= =?UTF-8?q?Transactional=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/application/payment/PaymentService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/moabam/api/application/payment/PaymentService.java b/src/main/java/com/moabam/api/application/payment/PaymentService.java index 2d6137e3..80b388a8 100644 --- a/src/main/java/com/moabam/api/application/payment/PaymentService.java +++ b/src/main/java/com/moabam/api/application/payment/PaymentService.java @@ -38,6 +38,7 @@ public void request(Long memberId, Long paymentId, PaymentRequest request) { payment.request(request.orderId()); } + @Transactional public RequestConfirmPaymentResponse requestConfirm(Long memberId, ConfirmPaymentRequest request) { Payment payment = getByOrderId(request.orderId()); payment.validateInfo(memberId, request.amount()); From 245db4cda546547b8c11191e88c47e8513e8158e Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Thu, 30 Nov 2023 17:35:43 +0900 Subject: [PATCH 128/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A0=95=EB=B3=B4=EC=97=90=20=EB=B0=A9=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=82=A0=EC=A7=9C=EC=8B=9C=EA=B0=84=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/application/room/mapper/RoomMapper.java | 1 + src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java | 2 ++ 2 files changed, 3 insertions(+) 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 index 8282c0ee..aee5dd5d 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -45,6 +45,7 @@ public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room List todayCertificateRankResponses, double completePercentage) { return RoomDetailsResponse.builder() .roomId(room.getId()) + .roomCreatedAt(room.getCreatedAt()) .myMemberId(memberId) .title(room.getTitle()) .managerNickName(managerNickname) diff --git a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java index 339ecbcc..cd019558 100644 --- a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -1,6 +1,7 @@ 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; @@ -10,6 +11,7 @@ @Builder public record RoomDetailsResponse( Long roomId, + LocalDateTime roomCreatedAt, Long myMemberId, String title, String managerNickName, From 154b7b3ef55d87c2a5a78133e92fa2a181fb01e6 Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 30 Nov 2023 19:08:02 +0900 Subject: [PATCH 129/185] =?UTF-8?q?fix:=20ObjectMapper=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 * fix: ObjectMapper에러 수정 --------- Co-authored-by: Kim Heebin --- .../java/com/moabam/global/config/EmbeddedRedisConfig.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java index a823fd26..85e7f64a 100644 --- a/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java +++ b/src/main/java/com/moabam/global/config/EmbeddedRedisConfig.java @@ -9,6 +9,7 @@ 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; @@ -64,8 +65,9 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } + @Order(2) @Bean - public ObjectMapper objectMapper() { + public ObjectMapper objectRedisMapper() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModules(new JavaTimeModule()); From b0ebebe97509ececa69c48ddce5bf1b19907b42f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=99=8D=ED=98=81=EC=A4=80?= <31675711+HyuckJuneHong@users.noreply.github.com> Date: Thu, 30 Nov 2023 19:23:40 +0900 Subject: [PATCH 130/185] =?UTF-8?q?=08refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=98=95=EC=8B=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20(#203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 푸시 알림 메시지 Body 변경 * refactor: FCM 알림 형식 변경 --- .../notification/NotificationService.java | 17 +++++++---- .../api/application/room/RoomService.java | 6 ---- .../api/infrastructure/fcm/FcmMapper.java | 6 ++-- .../api/infrastructure/fcm/FcmService.java | 4 +-- .../notification/NotificationServiceTest.java | 28 +++++++++++++------ .../infrastructure/fcm/FcmServiceTest.java | 4 +-- .../moabam/support/fixture/RoomFixture.java | 1 + 7 files changed, 37 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 3296fce5..41ca7539 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -28,8 +28,9 @@ @Transactional(readOnly = true) public class NotificationService { - private static final String KNOCK_BODY = "%s님이 콕 찔렀습니다."; - private static final String CERTIFY_TIME_BODY = "%s방 인증 시간입니다."; + private static final String COMMON_TITLE = "모아밤"; + private static final String KNOCK_BODY = "%s방에서 %s님이 콕 찔렀어요~"; + private static final String CERTIFY_TIME_BODY = "%s방 인증 시간~"; private final ClockHolder clockHolder; private final FcmService fcmService; @@ -40,18 +41,21 @@ public class NotificationService { @Transactional public void sendKnock(Long roomId, Long targetId, Long memberId, String memberNickname) { - roomService.validateRoomById(roomId); + String roomTitle = roomService.findRoom(roomId).getTitle(); validateConflictKnock(roomId, targetId, memberId); String fcmToken = fcmService.findTokenByMemberId(targetId) .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_FCM_TOKEN)); - fcmService.sendAsync(fcmToken, String.format(KNOCK_BODY, memberNickname)); + 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); - fcmService.sendAsync(fcmToken, String.format(body, couponName)); + String notificationBody = String.format(body, couponName); + fcmService.sendAsync(fcmToken, COMMON_TITLE, notificationBody); } @Scheduled(cron = "0 50 * * * *") @@ -61,9 +65,10 @@ public void sendCertificationTime() { 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, notificationBody); + fcmService.sendAsync(fcmToken, notificationTitle, notificationBody); }); } diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 658d7e0e..63179715 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -161,12 +161,6 @@ public boolean checkIfParticipant(Long memberId, Long roomId) { } } - public void validateRoomById(Long roomId) { - if (!roomRepository.existsById(roomId)) { - throw new NotFoundException(ROOM_NOT_FOUND); - } - } - public Room findRoom(Long roomId) { return roomRepository.findById(roomId) .orElseThrow(() -> new NotFoundException(ROOM_NOT_FOUND)); diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java index f977fc84..6b6a33a6 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmMapper.java @@ -9,11 +9,9 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class FcmMapper { - private static final String NOTIFICATION_TITLE = "모아밤"; - - public static Notification toNotification(String body) { + public static Notification toNotification(String title, String body) { return Notification.builder() - .setTitle(NOTIFICATION_TITLE) + .setTitle(title) .setBody(body) .build(); } diff --git a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java index cc575968..6b9fa73b 100644 --- a/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java +++ b/src/main/java/com/moabam/api/infrastructure/fcm/FcmService.java @@ -33,8 +33,8 @@ public Optional findTokenByMemberId(Long targetId) { return Optional.ofNullable(fcmRepository.findTokenByMemberId(targetId)); } - public void sendAsync(String fcmToken, String notificationBody) { - Notification notification = FcmMapper.toNotification(notificationBody); + public void sendAsync(String fcmToken, String notificationTitle, String notificationBody) { + Notification notification = FcmMapper.toNotification(notificationTitle, notificationBody); if (fcmToken != null) { Message message = FcmMapper.toMessage(notification, fcmToken); diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 2a49ab6f..10e0cdc9 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -19,6 +19,7 @@ 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.Room; import com.moabam.api.domain.room.repository.ParticipantSearchRepository; import com.moabam.api.infrastructure.fcm.FcmService; import com.moabam.global.auth.model.AuthMember; @@ -29,6 +30,7 @@ import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.FilterProcessExtension; +import com.moabam.support.fixture.RoomFixture; @ExtendWith({MockitoExtension.class, FilterProcessExtension.class}) class NotificationServiceTest { @@ -57,7 +59,9 @@ class NotificationServiceTest { @Test void sendKnock_success() { // Given - willDoNothing().given(roomService).validateRoomById(any(Long.class)); + Room room = RoomFixture.room(); + + given(roomService.findRoom(any(Long.class))).willReturn(room); 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); @@ -66,7 +70,7 @@ void sendKnock_success() { notificationService.sendKnock(1L, 1L, 2L, "nickName"); // Then - verify(fcmService).sendAsync(any(String.class), any(String.class)); + verify(fcmService).sendAsync(any(String.class), any(String.class), any(String.class)); verify(notificationRepository).saveKnock(any(Long.class), any(Long.class), any(Long.class)); } @@ -74,7 +78,7 @@ void sendKnock_success() { @Test void sendKnock_Room_NotFoundException() { // Given - willThrow(NotFoundException.class).given(roomService).validateRoomById(any(Long.class)); + given(roomService.findRoom(any(Long.class))).willThrow(NotFoundException.class); // When & Then assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L, "nickName")) @@ -85,7 +89,9 @@ void sendKnock_Room_NotFoundException() { @Test void sendKnock_FcmToken_NotFoundException() { // Given - willDoNothing().given(roomService).validateRoomById(any(Long.class)); + Room room = RoomFixture.room(); + + given(roomService.findRoom(any(Long.class))).willReturn(room); given(fcmService.findTokenByMemberId(any(Long.class))).willReturn(Optional.empty()); given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) .willReturn(false); @@ -100,7 +106,9 @@ void sendKnock_FcmToken_NotFoundException() { @Test void sendKnock_ConflictException() { // Given - willDoNothing().given(roomService).validateRoomById(any(Long.class)); + Room room = RoomFixture.room(); + + given(roomService.findRoom(any(Long.class))).willReturn(room); given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) .willReturn(true); @@ -120,7 +128,7 @@ void sendCouponIssueResult_success() { notificationService.sendCouponIssueResult(1L, "couponName", successIssueResult); // Then - verify(fcmService).sendAsync(any(String.class), any(String.class)); + verify(fcmService).sendAsync(any(String.class), any(String.class), any(String.class)); } @DisplayName("로그아웃된 사용자에게 쿠폰 이슈 결과를 성공적으로 전송한다. - Void") @@ -133,7 +141,7 @@ void sendCouponIssueResult_fcmToken_null() { notificationService.sendCouponIssueResult(1L, "couponName", successIssueResult); // Then - verify(fcmService).sendAsync(isNull(), any(String.class)); + verify(fcmService).sendAsync(isNull(), any(String.class), any(String.class)); } @DisplayName("특정 인증 시간에 해당하는 방 사용자들에게 알림을 성공적으로 보낸다. - Void") @@ -149,7 +157,8 @@ void sendCertificationTime_success(List participants) { notificationService.sendCertificationTime(); // Then - verify(fcmService, times(3)).sendAsync(any(String.class), any(String.class)); + verify(fcmService, times(3)) + .sendAsync(any(String.class), any(String.class), any(String.class)); } @DisplayName("특정 인증 시간에 해당하는 방 사용자들의 토큰값이 없다. - Void") @@ -165,7 +174,8 @@ void sendCertificationTime_NoFirebaseMessaging(List participants) { notificationService.sendCertificationTime(); // Then - verify(fcmService, times(0)).sendAsync(any(String.class), any(String.class)); + verify(fcmService, times(0)) + .sendAsync(any(String.class), any(String.class), any(String.class)); } @WithMember diff --git a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java index 942cf849..9720b843 100644 --- a/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java +++ b/src/test/java/com/moabam/api/infrastructure/fcm/FcmServiceTest.java @@ -79,7 +79,7 @@ void findTokenByMemberId_success() { @Test void sendAsync_success() { // When - fcmService.sendAsync("FCM-TOKEN", "알림"); + fcmService.sendAsync("FCM-TOKEN", "title", "body"); // Then verify(firebaseMessaging).sendAsync(any(Message.class)); @@ -89,7 +89,7 @@ void sendAsync_success() { @Test void sendAsync_null() { // When - fcmService.sendAsync(null, "알림"); + fcmService.sendAsync(null, "titile", "body"); // Then verify(firebaseMessaging, times(0)).sendAsync(any(Message.class)); diff --git a/src/test/java/com/moabam/support/fixture/RoomFixture.java b/src/test/java/com/moabam/support/fixture/RoomFixture.java index dbe7213d..b286c880 100644 --- a/src/test/java/com/moabam/support/fixture/RoomFixture.java +++ b/src/test/java/com/moabam/support/fixture/RoomFixture.java @@ -30,6 +30,7 @@ public static Room room() { public static Room room(int certifyTime) { return Room.builder() + .id(1L) .title("testTitle") .roomType(RoomType.MORNING) .certifyTime(certifyTime) From cc7f0d76a92655b4972fc4c20ec84495271e94af Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Thu, 30 Nov 2023 19:28:28 +0900 Subject: [PATCH 131/185] =?UTF-8?q?fix:=20ObjectMapper=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EC=8B=A4=ED=8C=A8=20=EC=88=98=EC=A0=95=20(#204)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 * fix: ObjectMapper에러 수정 * fix: objectMapper 삭제 추가 --------- Co-authored-by: Kim Heebin --- .../java/com/moabam/global/config/RedisConfig.java | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/main/java/com/moabam/global/config/RedisConfig.java b/src/main/java/com/moabam/global/config/RedisConfig.java index cc412271..9d017cce 100644 --- a/src/main/java/com/moabam/global/config/RedisConfig.java +++ b/src/main/java/com/moabam/global/config/RedisConfig.java @@ -10,9 +10,6 @@ import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; - @Configuration @Profile("!test") public class RedisConfig { @@ -38,12 +35,4 @@ public RedisTemplate redisTemplate(RedisConnectionFactory redisC return redisTemplate; } - - @Bean - public ObjectMapper objectMapper() { - ObjectMapper objectMapper = new ObjectMapper(); - objectMapper.registerModules(new JavaTimeModule()); - - return objectMapper; - } } From 2eeb47740aa68e2acf4c5e5e8d60912ab2a05b0e Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Thu, 30 Nov 2023 20:25:22 +0900 Subject: [PATCH 132/185] =?UTF-8?q?hotfix:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EB=82=B4=EC=9A=A9=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EB=B0=8F=20item-data=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mysql/initdb.d/item-data.sql | 9 +++++ .../notification/NotificationService.java | 12 ++++-- .../presentation/NotificationController.java | 2 +- .../notification/NotificationServiceTest.java | 38 ++++++++++++++----- .../NotificationControllerTest.java | 12 ++++-- 5 files changed, 54 insertions(+), 19 deletions(-) diff --git a/mysql/initdb.d/item-data.sql b/mysql/initdb.d/item-data.sql index 7030418f..28561b81 100644 --- a/mysql/initdb.d/item-data.sql +++ b/mysql/initdb.d/item-data.sql @@ -37,3 +37,12 @@ values ('MORNING', 'SKIN', '산타 오목눈이', 'https://image.moabam.com/moab 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', 30, 15, 15, current_time()); + +insert into product (id, type, name, price, quantity, created_at, updated_at) +values (null, 'BUG', '황금벌레x5', 3300, 5, current_time(), null); + +insert into product (id, type, name, price, quantity, created_at, updated_at) +values (null, 'BUG', '황금벌레x10', 6600, 10, current_time(), null); + +insert into product (id, type, name, price, quantity, created_at, updated_at) +values (null, 'BUG', '황금벌레x15', 9900, 15, current_time(), null); diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 41ca7539..523ac965 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -11,6 +11,7 @@ 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; @@ -29,24 +30,27 @@ 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방 인증 시간~"; + private static final String KNOCK_BODY = "[%s] - [%s]님이 콕콕콕!"; + private static final String CERTIFY_TIME_BODY = "[%s] - 인증 시간!"; 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, String memberNickname) { - String roomTitle = roomService.findRoom(roomId).getTitle(); + 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); diff --git a/src/main/java/com/moabam/api/presentation/NotificationController.java b/src/main/java/com/moabam/api/presentation/NotificationController.java index 6b5287d9..85434343 100644 --- a/src/main/java/com/moabam/api/presentation/NotificationController.java +++ b/src/main/java/com/moabam/api/presentation/NotificationController.java @@ -31,7 +31,7 @@ public void sendKnock( @PathVariable("memberId") Long memberId, @Auth AuthMember authMember ) { - notificationService.sendKnock(roomId, memberId, authMember.id(), authMember.nickname()); + notificationService.sendKnock(roomId, memberId, authMember.id()); } @PostMapping diff --git a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java index 10e0cdc9..a08d2b06 100644 --- a/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/notification/NotificationServiceTest.java @@ -16,7 +16,9 @@ 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; @@ -30,6 +32,7 @@ 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}) @@ -38,6 +41,9 @@ class NotificationServiceTest { @InjectMocks NotificationService notificationService; + @Mock + MemberService memberService; + @Mock RoomService roomService; @@ -60,44 +66,59 @@ class NotificationServiceTest { 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, "nickName"); + 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") + @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, "nickName")) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) .isInstanceOf(NotFoundException.class); } - @DisplayName("콕 찌를 상대의 FCM 토큰이 존재하지 않는다. - NotFoundException") + @DisplayName("콕 찌를 상대가 존재하지 않는다. - NotFoundException") @Test - void sendKnock_FcmToken_NotFoundException() { + 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, "nickName")) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.NOT_FOUND_FCM_TOKEN.getMessage()); } @@ -106,14 +127,11 @@ void sendKnock_FcmToken_NotFoundException() { @Test void sendKnock_ConflictException() { // Given - Room room = RoomFixture.room(); - - given(roomService.findRoom(any(Long.class))).willReturn(room); given(notificationRepository.existsKnockByKey(any(Long.class), any(Long.class), any(Long.class))) .willReturn(true); // When & Then - assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L, "nickName")) + assertThatThrownBy(() -> notificationService.sendKnock(1L, 1L, 2L)) .isInstanceOf(ConflictException.class) .hasMessage(ErrorMessage.CONFLICT_KNOCK.getMessage()); } diff --git a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java index 400ce3ee..b623bcb9 100644 --- a/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/NotificationControllerTest.java @@ -8,9 +8,10 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; +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; @@ -41,6 +42,7 @@ @SpringBootTest @AutoConfigureMockMvc @AutoConfigureRestDocs +@TestInstance(TestInstance.Lifecycle.PER_CLASS) class NotificationControllerTest extends WithoutFilterSupporter { @Autowired @@ -67,15 +69,17 @@ class NotificationControllerTest extends WithoutFilterSupporter { @MockBean FirebaseMessaging firebaseMessaging; + Member member; Member target; Room room; String knockKey; - @BeforeEach + @BeforeAll void setUp() { - target = memberRepository.save(MemberFixture.member("123")); + member = memberRepository.save(MemberFixture.member(1L)); + target = memberRepository.save(MemberFixture.member("socialId")); room = roomRepository.save(RoomFixture.room()); - knockKey = String.format("room_%s_member_%s_knocks_%s", room.getId(), 1, target.getId()); + knockKey = String.format("roomId=%s_targetId=%s_memberId=%s", room.getId(), target.getId(), member.getId()); willReturn(null) .given(firebaseMessaging) From 0e9742007b191f8292538ae0543eb86c545ae407 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 22:28:11 +0900 Subject: [PATCH 133/185] =?UTF-8?q?refactor:=20infra=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81=20(#206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: infra 디렉토리 생성 및 리팩터링 * fix: 초기 아이템 데이터 이미지 링크 수정 --- .github/workflows/ci.yml | 4 -- .github/workflows/develop-cd.yml | 40 +++++++++---------- Dockerfile => infra/Dockerfile | 2 +- .../docker-compose-dev.yml | 18 ++++----- {mysql => infra/mysql}/initdb.d/init.sql | 0 {mysql => infra/mysql}/initdb.d/item-data.sql | 18 ++++----- {nginx => infra/nginx}/conf.d/header.conf | 0 {nginx => infra/nginx}/mime.types | 0 {nginx => infra/nginx}/nginx.conf | 0 .../nginx}/templates/http-server.template | 0 .../nginx}/templates/ssl-server.template | 0 .../nginx}/templates/upstream.template | 0 {scripts => infra/scripts}/deploy-dev.sh | 0 .../scripts}/init-letsencrypt.sh | 0 .../scripts}/init-nginx-converter.sh | 0 15 files changed, 39 insertions(+), 43 deletions(-) rename Dockerfile => infra/Dockerfile (78%) rename docker-compose-dev.yml => infra/docker-compose-dev.yml (75%) rename {mysql => infra/mysql}/initdb.d/init.sql (100%) rename {mysql => infra/mysql}/initdb.d/item-data.sql (88%) rename {nginx => infra/nginx}/conf.d/header.conf (100%) rename {nginx => infra/nginx}/mime.types (100%) rename {nginx => infra/nginx}/nginx.conf (100%) rename {nginx => infra/nginx}/templates/http-server.template (100%) rename {nginx => infra/nginx}/templates/ssl-server.template (100%) rename {nginx => infra/nginx}/templates/upstream.template (100%) rename {scripts => infra/scripts}/deploy-dev.sh (100%) rename {scripts => infra/scripts}/init-letsencrypt.sh (100%) rename {scripts => infra/scripts}/init-nginx-converter.sh (100%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f94d5a7..17e57edd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,10 +21,6 @@ jobs: java-version: '17' distribution: 'corretto' - - name: environment 세팅 - run: | - cp src/main/resources/config/dev.env ./.env - - name: Gradle 캐싱 uses: actions/cache@v3 with: diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index bca6e32e..987b4b6a 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -30,41 +30,41 @@ jobs: - name: Github Actions IP 보안그룹 추가 run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + 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_INSTANCE_HOST }} + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + 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 ./.env + cp src/main/resources/config/dev.env ./infra/.env - name: 서버로 전송 기본 파일들 전송 uses: appleboy/scp-action@master with: - host: ${{ secrets.EC2_INSTANCE_HOST }} + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} port: 22 - username: ${{ secrets.EC2_INSTANCE_USERNAME }} - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "./.env, ./docker-compose-dev.yml, ./scripts/*, ./nginx/*, ./mysql/*" + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + source: "./infra/*" target: "/home/ubuntu/moabam" - name: 파일 세팅 uses: appleboy/ssh-action@master with: - host: ${{ secrets.EC2_INSTANCE_HOST }} + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} script: | - cd /home/ubuntu/moabam + 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 @@ -75,7 +75,7 @@ jobs: - name: Github Actions IP 보안그룹에서 삭제 if: always() run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + 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 @@ -148,18 +148,18 @@ jobs: - name: Github Actions IP 보안그룹 추가 run: | - aws ec2 authorize-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + 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_INSTANCE_HOST }} + host: ${{ secrets.EC2_DEV_INSTANCE_HOST }} port: 22 - username: ubuntu - key: ${{ secrets.EC2_INSTANCE_PRIVATE_KEY }} - source: "docker-compose-dev.yml" + username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} + key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} + source: "./infra/docker-compose-dev.yml" script: | cd /home/ubuntu/moabam echo ${{ secrets.DOCKER_HUB_TOKEN }} | docker login -u ${{ secrets.DOCKER_HUB_USERNAME }} --password-stdin @@ -171,4 +171,4 @@ jobs: - name: Github Actions IP 보안그룹에서 삭제 if: always() run: | - aws ec2 revoke-security-group-ingress --group-id ${{ secrets.AWS_SG_ID }} --protocol tcp --port 22 --cidr ${{ steps.ip.outputs.ipv4 }}/32 + 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/Dockerfile b/infra/Dockerfile similarity index 78% rename from Dockerfile rename to infra/Dockerfile index c7fb704b..8fb6a8a5 100644 --- a/Dockerfile +++ b/infra/Dockerfile @@ -3,6 +3,6 @@ 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 +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/docker-compose-dev.yml b/infra/docker-compose-dev.yml similarity index 75% rename from docker-compose-dev.yml rename to infra/docker-compose-dev.yml index 5d953072..35acfe2e 100644 --- a/docker-compose-dev.yml +++ b/infra/docker-compose-dev.yml @@ -10,18 +10,18 @@ services: - "80:80" - "443:443" volumes: - - /home/ubuntu/moabam/nginx/nginx.conf:/etc/nginx/nginx.conf - - /home/ubuntu/moabam/nginx/conf.d:/etc/nginx/conf.d - - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt - - /home/ubuntu/moabam/nginx/certbot/www:/var/www/certbot + - ./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 certbot: image: certbot/certbot:latest container_name: certbot platform: linux/arm64 restart: unless-stopped volumes: - - /home/ubuntu/moabam/nginx/certbot/conf:/etc/letsencrypt - - /home/ubuntu/moabam/nginx/certbot/www:/var/www/certbot + - ./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} @@ -54,7 +54,7 @@ services: ports: - "6379:6379" volumes: - - /home/ubuntu/moabam/data/redis:/data + - ./data/redis:/data mysql: image: mysql:8.0.33 container_name: mysql @@ -72,5 +72,5 @@ services: - --collation-server=utf8mb4_unicode_ci - --skip-character-set-client-handshake volumes: - - /home/ubuntu/moabam/data/mysql:/var/lib/mysql - - /home/ubuntu/moabam/mysql/initdb.d:/docker-entrypoint-initdb.d + - ./data/mysql:/var/lib/mysql + - ./mysql/initdb.d:/docker-entrypoint-initdb.d diff --git a/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql similarity index 100% rename from mysql/initdb.d/init.sql rename to infra/mysql/initdb.d/init.sql diff --git a/mysql/initdb.d/item-data.sql b/infra/mysql/initdb.d/item-data.sql similarity index 88% rename from mysql/initdb.d/item-data.sql rename to infra/mysql/initdb.d/item-data.sql index 28561b81..7243750c 100644 --- a/mysql/initdb.d/item-data.sql +++ b/infra/mysql/initdb.d/item-data.sql @@ -16,33 +16,33 @@ values ('NIGHT', 'SKIN', '부엉이', 'https://image.moabam.com/moabam/skins/owl 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', 10, 5, 5, current_time()); + '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', 10, 5, 5, current_time()); + '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', 20, 10, 10, current_time()); + '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', 20, 10, 10, current_time()); + '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', 30, 15, 15, current_time()); + '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', 30, 15, 15, current_time()); + 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed.png', 30, 15, 15, current_time()); insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x5', 3300, 5, current_time(), null); +values (null, 'BUG', '황금벌레x5', 3000, 5, current_time(), null); insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x10', 6600, 10, current_time(), null); +values (null, 'BUG', '황금벌레x10', 7000, 10, current_time(), null); insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x15', 9900, 15, current_time(), null); +values (null, 'BUG', '황금벌레x25', 9900, 25, current_time(), null); diff --git a/nginx/conf.d/header.conf b/infra/nginx/conf.d/header.conf similarity index 100% rename from nginx/conf.d/header.conf rename to infra/nginx/conf.d/header.conf diff --git a/nginx/mime.types b/infra/nginx/mime.types similarity index 100% rename from nginx/mime.types rename to infra/nginx/mime.types diff --git a/nginx/nginx.conf b/infra/nginx/nginx.conf similarity index 100% rename from nginx/nginx.conf rename to infra/nginx/nginx.conf diff --git a/nginx/templates/http-server.template b/infra/nginx/templates/http-server.template similarity index 100% rename from nginx/templates/http-server.template rename to infra/nginx/templates/http-server.template diff --git a/nginx/templates/ssl-server.template b/infra/nginx/templates/ssl-server.template similarity index 100% rename from nginx/templates/ssl-server.template rename to infra/nginx/templates/ssl-server.template diff --git a/nginx/templates/upstream.template b/infra/nginx/templates/upstream.template similarity index 100% rename from nginx/templates/upstream.template rename to infra/nginx/templates/upstream.template diff --git a/scripts/deploy-dev.sh b/infra/scripts/deploy-dev.sh similarity index 100% rename from scripts/deploy-dev.sh rename to infra/scripts/deploy-dev.sh diff --git a/scripts/init-letsencrypt.sh b/infra/scripts/init-letsencrypt.sh similarity index 100% rename from scripts/init-letsencrypt.sh rename to infra/scripts/init-letsencrypt.sh diff --git a/scripts/init-nginx-converter.sh b/infra/scripts/init-nginx-converter.sh similarity index 100% rename from scripts/init-nginx-converter.sh rename to infra/scripts/init-nginx-converter.sh From 8d7d3ef73c9f430b218865f5cc5e8cd08edb4ccb Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 22:36:57 +0900 Subject: [PATCH 134/185] =?UTF-8?q?refactor:=20infra=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81=20(#207)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: infra 디렉토리 생성 및 리팩터링 * fix: 초기 아이템 데이터 이미지 링크 수정 * fix: DockerFile 경로 수정 --- .github/workflows/develop-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 987b4b6a..2a6558a3 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -126,7 +126,7 @@ jobs: - name: Docker Hub 빌드하고 푸시 uses: docker/build-push-action@v4 with: - context: . + context: ./infra push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} build-args: | From 8e4cc3a7964b30a1df78db23f735516dcb0798fd Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 22:59:59 +0900 Subject: [PATCH 135/185] =?UTF-8?q?refactor:=20infra=20=EB=94=94=EB=A0=89?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=83=9D=EC=84=B1=20=EB=B0=8F=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=84=B0=EB=A7=81=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: infra 디렉토리 생성 및 리팩터링 * fix: 초기 아이템 데이터 이미지 링크 수정 * fix: DockerFile 경로 수정 * fix: 쉘 스크립트 경로 수정 * feat: nginx 로깅 추가 * feat: actuator 외부 차단 --- infra/nginx/templates/ssl-server.template | 6 ++++++ infra/scripts/deploy-dev.sh | 8 ++++---- infra/scripts/init-letsencrypt.sh | 6 +++--- infra/scripts/init-nginx-converter.sh | 10 +++++----- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/infra/nginx/templates/ssl-server.template b/infra/nginx/templates/ssl-server.template index 8bd2f677..3fdca313 100644 --- a/infra/nginx/templates/ssl-server.template +++ b/infra/nginx/templates/ssl-server.template @@ -1,7 +1,13 @@ server { listen 443 ssl; server_name ${SERVER_DOMAIN}; + access_log /home/ubuntu/moabam/logs/access_ssl_moabam.log main; + error_log /home/ubuntu/moabam/logs/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; diff --git a/infra/scripts/deploy-dev.sh b/infra/scripts/deploy-dev.sh index d11b09c0..cc6debda 100644 --- a/infra/scripts/deploy-dev.sh +++ b/infra/scripts/deploy-dev.sh @@ -1,8 +1,8 @@ #!/bin/bash # .env 파일 로드 -if [ -f /home/ubuntu/moabam/.env ]; then - source /home/ubuntu/moabam/.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 @@ -46,8 +46,8 @@ echo "### springboot blue-green 무중단 배포 시작 ###" echo IS_BLUE=$(docker ps | grep ${BLUE_CONTAINER}) -NGINX_CONF="/home/ubuntu/moabam/nginx/nginx.conf" -UPSTREAM_CONF="/home/ubuntu/moabam/nginx/conf.d/upstream.conf" +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 ###" diff --git a/infra/scripts/init-letsencrypt.sh b/infra/scripts/init-letsencrypt.sh index b9040e36..1031fd25 100644 --- a/infra/scripts/init-letsencrypt.sh +++ b/infra/scripts/init-letsencrypt.sh @@ -1,8 +1,8 @@ #!/bin/bash # .env 파일 로드 -if [ -f /home/ubuntu/moabam/.env ]; then - source /home/ubuntu/moabam/.env +if [ -f /home/ubuntu/moabam/infra/.env ]; then + source /home/ubuntu/moabam/infra/.env fi if ! [ -x "$(command -v docker-compose)" ]; then @@ -12,7 +12,7 @@ fi domains="${SERVER_DOMAIN}" rsa_key_size=4096 -data_path="/home/ubuntu/moabam/nginx/certbot" +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 diff --git a/infra/scripts/init-nginx-converter.sh b/infra/scripts/init-nginx-converter.sh index 934f88c5..861b2aeb 100644 --- a/infra/scripts/init-nginx-converter.sh +++ b/infra/scripts/init-nginx-converter.sh @@ -1,14 +1,14 @@ #!/bin/bash # .env 파일 로드 -if [ -f /home/ubuntu/moabam/.env ]; then - source /home/ubuntu/moabam/.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/nginx/templates/http-server.template > /home/ubuntu/moabam/nginx/conf.d/http-server.conf -envsubst '$SERVER_DOMAIN' < /home/ubuntu/moabam/nginx/templates/ssl-server.template > /home/ubuntu/moabam/nginx/conf.d/ssl-server.conf -envsubst '$BLUE_CONTAINER $SERVER_PORT' < /home/ubuntu/moabam/nginx/templates/upstream.template > /home/ubuntu/moabam/nginx/conf.d/upstream.conf +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 From 8af27008f5d0beb73b8fba6850960a7b6e763997 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 23:10:49 +0900 Subject: [PATCH 136/185] =?UTF-8?q?hotfix:=20Dockerfile=20copy=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/Dockerfile b/infra/Dockerfile index 8fb6a8a5..c7fb704b 100644 --- a/infra/Dockerfile +++ b/infra/Dockerfile @@ -3,6 +3,6 @@ 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 +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"] From 4756d60753b0f4f226c4035eba849fc66f47601e Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 23:16:03 +0900 Subject: [PATCH 137/185] =?UTF-8?q?hotfix:=20deploy-cd=20Dockerfile=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 2a6558a3..d38ae7d7 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -126,7 +126,8 @@ jobs: - name: Docker Hub 빌드하고 푸시 uses: docker/build-push-action@v4 with: - context: ./infra + context: . + file: ./infra/Dockerfile push: true tags: ${{ secrets.DOCKER_HUB_USERNAME }}/${{ secrets.DOCKER_HUB_REPOSITORY }}:${{ secrets.DOCKER_HUB_DEV_TAG }} build-args: | From 56386e89cc89ce88c07e5723163d9ffd1826b6a6 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 23:21:22 +0900 Subject: [PATCH 138/185] =?UTF-8?q?hotfix:=20deploy-cd=20=EC=89=98=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index d38ae7d7..cd8199cb 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -162,7 +162,7 @@ jobs: key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} source: "./infra/docker-compose-dev.yml" script: | - cd /home/ubuntu/moabam + 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` From a806cbdac89a59db038744acdf5da0041d158737 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Thu, 30 Nov 2023 23:34:57 +0900 Subject: [PATCH 139/185] =?UTF-8?q?hotfix:=20nginx=20=EB=A1=9C=EA=B9=85=20?= =?UTF-8?q?docker-compose=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/docker-compose-dev.yml | 1 + infra/nginx/templates/ssl-server.template | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/infra/docker-compose-dev.yml b/infra/docker-compose-dev.yml index 35acfe2e..86ec3530 100644 --- a/infra/docker-compose-dev.yml +++ b/infra/docker-compose-dev.yml @@ -14,6 +14,7 @@ services: - ./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 diff --git a/infra/nginx/templates/ssl-server.template b/infra/nginx/templates/ssl-server.template index 3fdca313..46db85fb 100644 --- a/infra/nginx/templates/ssl-server.template +++ b/infra/nginx/templates/ssl-server.template @@ -1,8 +1,8 @@ server { listen 443 ssl; server_name ${SERVER_DOMAIN}; - access_log /home/ubuntu/moabam/logs/access_ssl_moabam.log main; - error_log /home/ubuntu/moabam/logs/error.log error; + access_log /var/log/nginx/access.log main; + error_log /var/log/nginx/error.log error; location ^~ /actuator { return 404; From 90b33cd744b84dec7a04639e8ec5a9b7aa2e3fd7 Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 00:22:04 +0900 Subject: [PATCH 140/185] =?UTF-8?q?hotfix:=20String=20to=20Long=20Error=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/domain/coupon/repository/CouponManageRepository.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 index 5dc248fe..9065e378 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -31,10 +31,11 @@ public void addIfAbsentQueue(String couponName, Long memberId, double registerTi } public Set rangeQueue(String couponName, long start, long end) { + return zSetRedisRepository .range(requireNonNull(couponName), start, end) .stream() - .map(Long.class::cast) + .map(memberId -> Long.parseLong(String.valueOf(memberId))) .collect(Collectors.toSet()); } From e1e747735a87b76125855e387c51f8c0feabf481 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 1 Dec 2023 00:58:54 +0900 Subject: [PATCH 141/185] =?UTF-8?q?fix:=20MaxUploadSizeExceededException?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=20=EB=8D=98=EC=A7=80=EA=B8=B0=20(#212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../error/handler/GlobalExceptionHandler.java | 13 ++++++++++++- src/main/resources/config | 2 +- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index d45037c4..4d68a475 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -1,6 +1,8 @@ package com.moabam.global.error.handler; -import static com.moabam.global.error.model.ErrorMessage.*; +import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_FIELD; +import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_VALUE_TYPE_FORMAT; +import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE_SIZE; import java.util.HashMap; import java.util.List; @@ -14,6 +16,7 @@ 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; @@ -99,4 +102,12 @@ public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTyp return new ErrorResponse(message, null); } + + @ExceptionHandler(MaxUploadSizeExceededException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public ErrorResponse handleMaxSizeException(MaxUploadSizeExceededException exception) { + String message = String.format(S3_INVALID_IMAGE_SIZE.getMessage()); + + return new ErrorResponse(message, null); + } } diff --git a/src/main/resources/config b/src/main/resources/config index 4dbde487..c9c58dc4 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 4dbde487226312c2a144165aa28c0cc0b1e7a0bf +Subproject commit c9c58dc4c9fbcd88ca2ae51e09b883e296bd3db9 From 109e04cd3d2830bf285bbadc569a94e442704214 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Fri, 1 Dec 2023 01:29:53 +0900 Subject: [PATCH 142/185] =?UTF-8?q?fix:=200=EC=8B=9C=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=20=EB=B0=A9=EC=97=90=EC=84=9C=20=EC=9D=B8=EC=A6=9D=EC=9D=B4=20?= =?UTF-8?q?=EC=95=88=EB=90=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 0시 인증타임 예외처리 수정 * test: 테스트 수정 --- .../api/application/room/CertificationService.java | 10 ++++++++-- .../com/moabam/global/common/util/GlobalConstant.java | 1 + .../moabam/api/presentation/RoomControllerTest.java | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index 3a679550..e6a56ab3 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -1,5 +1,6 @@ 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; @@ -109,8 +110,13 @@ public Certification findCertification(Long certificationId) { private void validateCertifyTime(LocalDateTime now, int certifyTime) { LocalTime targetTime = LocalTime.of(certifyTime, 0); - LocalDateTime minusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).minusMinutes(10); - LocalDateTime plusTenMinutes = LocalDateTime.of(now.toLocalDate(), targetTime).plusMinutes(10); + LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); + if (certifyTime == MIDNIGHT_HOUR) { + targetDateTime = targetDateTime.plusDays(1); + } + + LocalDateTime minusTenMinutes = targetDateTime.minusMinutes(10); + LocalDateTime plusTenMinutes = targetDateTime.plusMinutes(10); if (now.isBefore(minusTenMinutes) || now.isAfter(plusTenMinutes)) { throw new BadRequestException(INVALID_CERTIFY_TIME); diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index a9f79ff3..259c3d13 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -12,6 +12,7 @@ public class GlobalConstant { 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; diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index a1c850e7..765d6f38 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -850,7 +850,7 @@ void get_room_details_test() throws Exception { dailyRoomCertificationRepository.save(dailyRoomCertification); DailyRoomCertification dailyRoomCertification1 = RoomFixture.dailyRoomCertification(room.getId(), - LocalDate.of(LocalDate.now().getYear(), LocalDate.now().getMonth(), LocalDate.now().getDayOfMonth() - 3)); + LocalDate.now().minusDays(3)); dailyRoomCertificationRepository.save(dailyRoomCertification1); // expected From 0fd11b35b64c4c062b1a7662b7644d33f186dbca Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 02:51:43 +0900 Subject: [PATCH 143/185] =?UTF-8?q?hotfix:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8A=94=20?= =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/mysql/initdb.d/init.sql | 2 +- .../coupon/CouponManageService.java | 46 ++++++++++--------- .../api/application/coupon/CouponMapper.java | 6 +-- .../com/moabam/api/domain/coupon/Coupon.java | 23 +++++----- .../repository/CouponManageRepository.java | 23 +++++++++- .../coupon/repository/CouponRepository.java | 2 + .../moabam/api/dto/coupon/CouponResponse.java | 2 +- .../api/dto/coupon/CreateCouponRequest.java | 2 +- .../redis/ValueRedisRepository.java | 4 +- .../redis/ZSetRedisRepository.java | 12 +++++ .../global/error/model/ErrorMessage.java | 1 + src/main/resources/static/docs/coupon.html | 24 +++++----- .../resources/static/docs/notification.html | 2 +- .../coupon/CouponManageServiceTest.java | 28 +++++------ .../application/coupon/CouponServiceTest.java | 2 +- .../moabam/api/domain/coupon/CouponTest.java | 4 +- .../redis/ValueRedisRepositoryTest.java | 2 +- .../presentation/CouponControllerTest.java | 21 --------- .../moabam/support/fixture/CouponFixture.java | 20 ++++---- .../moabam/support/snippet/CouponSnippet.java | 12 ++--- .../support/snippet/CouponWalletSnippet.java | 3 +- 21 files changed, 125 insertions(+), 116 deletions(-) diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql index 8f1f8cc4..b30d105f 100644 --- a/infra/mysql/initdb.d/init.sql +++ b/infra/mysql/initdb.d/init.sql @@ -40,7 +40,7 @@ create table coupon point integer default 1 not null, description varchar(50) default '', type enum ('DISCOUNT','GOLDEN','MORNING','NIGHT') not null, - stock integer default 1 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, diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index 09be5dc1..bf3d5934 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -15,6 +15,7 @@ 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.model.ErrorMessage; import lombok.RequiredArgsConstructor; @@ -28,7 +29,6 @@ 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 static final long ISSUE_FIRST = 0; private final ClockHolder clockHolder; private final NotificationService notificationService; @@ -37,13 +37,6 @@ public class CouponManageService { private final CouponManageRepository couponManageRepository; private final CouponWalletRepository couponWalletRepository; - private long current = ISSUE_FIRST; - - @Scheduled(cron = "0 0 0 * * *") - public void init() { - current = ISSUE_FIRST; - } - @Scheduled(fixedDelay = 1000) public void issue() { LocalDate now = clockHolder.date(); @@ -55,27 +48,26 @@ public void issue() { Coupon coupon = optionalCoupon.get(); String couponName = coupon.getName(); - int max = coupon.getStock(); + int maxCount = coupon.getMaxCount(); + int currentCount = couponManageRepository.getCouponCount(couponName); - Set membersId = couponManageRepository.rangeQueue(couponName, current, current + ISSUE_SIZE); - - for (Long memberId : membersId) { - int rank = couponManageRepository.rankQueue(couponName, memberId); + if (maxCount <= currentCount) { + return; + } - if (max < rank) { - notificationService.sendCouponIssueResult(memberId, couponName, FAIL_ISSUE_BODY); - continue; - } + Set membersId = couponManageRepository.rangeQueue(couponName, currentCount, currentCount + ISSUE_SIZE); + for (Long memberId : membersId) { couponWalletRepository.save(CouponWallet.create(memberId, coupon)); notificationService.sendCouponIssueResult(memberId, couponName, SUCCESS_ISSUE_BODY); - current++; } + + couponManageRepository.increase(couponName, membersId.size()); } public void registerQueue(String couponName, Long memberId) { double registerTime = System.currentTimeMillis(); - validateRegisterQueue(couponName); + validateRegisterQueue(couponName, memberId); couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime); } @@ -83,11 +75,21 @@ public void deleteQueue(String couponName) { couponManageRepository.deleteQueue(couponName); } - private void validateRegisterQueue(String couponName) { + private void validateRegisterQueue(String couponName, Long memberId) { LocalDate now = clockHolder.date(); + Coupon coupon = couponRepository.findByNameAndStartAt(couponName, now) + .orElseThrow(() -> new BadRequestException(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 (!couponRepository.existsByNameAndStartAt(couponName, now)) { - throw new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD); + 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 index d8799ab1..eae0c895 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -22,7 +22,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { .description(coupon.description()) .type(CouponType.from(coupon.type())) .point(coupon.point()) - .stock(coupon.stock()) + .maxCount(coupon.maxCount()) .startAt(coupon.startAt()) .openAt(coupon.openAt()) .adminId(adminId) @@ -33,11 +33,11 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { public static CouponResponse toResponse(Coupon coupon) { return CouponResponse.builder() .id(coupon.getId()) - .adminName(coupon.getAdminId() + "admin") + .adminName("ID : " + coupon.getAdminId()) .name(coupon.getName()) .description(coupon.getDescription()) .point(coupon.getPoint()) - .stock(coupon.getStock()) + .maxCount(coupon.getMaxCount()) .type(coupon.getType()) .startAt(coupon.getStartAt()) .openAt(coupon.getOpenAt()) diff --git a/src/main/java/com/moabam/api/domain/coupon/Coupon.java b/src/main/java/com/moabam/api/domain/coupon/Coupon.java index e539d1b0..a10a11a4 100644 --- a/src/main/java/com/moabam/api/domain/coupon/Coupon.java +++ b/src/main/java/com/moabam/api/domain/coupon/Coupon.java @@ -43,6 +43,10 @@ public class Coupon extends BaseTimeEntity { @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; @@ -51,33 +55,33 @@ public class Coupon extends BaseTimeEntity { @Column(name = "type", nullable = false) private CouponType type; - @ColumnDefault("1") - @Column(name = "stock", nullable = false) - private int stock; - @Column(name = "start_at", unique = true, nullable = false) private LocalDate startAt; @Column(name = "open_at", nullable = false) private LocalDate openAt; - // TODO : 관리자 테이블 생기면 관리자 테이블이랑 다대일 관계 맺을 예정 @Column(name = "admin_id", updatable = false, nullable = false) private Long adminId; @Builder - private Coupon(String name, String description, int point, int stock, CouponType type, LocalDate startAt, + 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.stock = validateStock(stock); 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); @@ -93,9 +97,4 @@ private int validateStock(int stock) { return stock; } - - @Override - public String toString() { - return String.format("Coupon{startAt=%s, openAt=%s}", startAt, openAt); - } } 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 index 9065e378..62864dde 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -2,6 +2,7 @@ import static java.util.Objects.*; +import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; @@ -16,6 +17,7 @@ @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; @@ -31,7 +33,6 @@ public void addIfAbsentQueue(String couponName, Long memberId, double registerTi } public Set rangeQueue(String couponName, long start, long end) { - return zSetRedisRepository .range(requireNonNull(couponName), start, end) .stream() @@ -39,12 +40,32 @@ public Set rangeQueue(String couponName, long start, long end) { .collect(Collectors.toSet()); } + public boolean hasValue(String couponName, Long memberId) { + return Objects.nonNull(zSetRedisRepository.score(couponName, memberId)); + } + + public int sizeQueue(String couponName) { + return zSetRedisRepository + .size(couponName) + .intValue(); + } + public int rankQueue(String couponName, Long memberId) { return zSetRedisRepository .rank(requireNonNull(couponName), requireNonNull(memberId)) .intValue(); } + public int getCouponCount(String couponName) { + String couponCountKey = String.format(COUPON_COUNT_KEY, couponName); + return Integer.parseInt(valueRedisRepository.get(couponCountKey)); + } + + public void increase(String couponName, long count) { + String couponCountKey = String.format(COUPON_COUNT_KEY, couponName); + valueRedisRepository.increment(couponCountKey, count); + } + public void deleteQueue(String couponName) { valueRedisRepository.delete(requireNonNull(couponName)); } 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 index a9e5c0a6..f236858d 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponRepository.java @@ -15,5 +15,7 @@ public interface CouponRepository extends JpaRepository { 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/dto/coupon/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java index ac408733..69b37827 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java +++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java @@ -14,7 +14,7 @@ public record CouponResponse( String name, String description, int point, - int stock, + int maxCount, CouponType type, @JsonFormat(pattern = "yyyy-MM-dd") LocalDate startAt, diff --git a/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java index 02ebc0c7..e3edcdfb 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java +++ b/src/main/java/com/moabam/api/dto/coupon/CreateCouponRequest.java @@ -17,7 +17,7 @@ public record CreateCouponRequest( @Length(max = 50, message = "쿠폰 간단 소개는 최대 50자까지 가능합니다.") String description, @NotBlank(message = "쿠폰 종류를 입력해주세요.") String type, @Min(value = 1, message = "벌레 수 혹은 할인 금액은 1 이상이어야 합니다.") int point, - @Min(value = 1, message = "쿠폰 재고는 1 이상이어야 합니다.") int stock, + @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") diff --git a/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java index 9f456e9a..8c879bcf 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ValueRedisRepository.java @@ -19,10 +19,10 @@ public void save(String key, String value, Duration timeout) { .set(key, value, timeout); } - public Long increment(String key) { + public Long increment(String key, long delta) { return redisTemplate .opsForValue() - .increment(key); + .increment(key, delta); } public String get(String 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 index d68e43b7..389ff604 100644 --- a/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java +++ b/src/main/java/com/moabam/api/infrastructure/redis/ZSetRedisRepository.java @@ -39,6 +39,18 @@ public Long rank(String key, Object value) { .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() diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index cd39ce68..d3c8d114 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -82,6 +82,7 @@ public enum ErrorMessage { INVALID_BUG_COUPON("벌레 쿠폰은 보관함에서 사용할 수 있습니다."), CONFLICT_COUPON_NAME("쿠폰의 이름이 중복되었습니다."), CONFLICT_COUPON_START_AT("쿠폰 발급 가능 날짜가 중복되었습니다."), + CONFLICT_COUPON_ISSUE("이미 쿠폰 발급에 성공했습니다!"), NOT_FOUND_COUPON_TYPE("존재하지 않는 쿠폰 종류입니다."), NOT_FOUND_COUPON("존재하지 않는 쿠폰입니다."), NOT_FOUND_COUPON_WALLET("보유하지 않은 쿠폰입니다."), diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index 14237f4b..f677031b 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -461,7 +461,7 @@

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 175
+Content-Length: 178
 Host: localhost:8080
 
 {
@@ -469,7 +469,7 @@ 

요청

"description" : "coupon description", "type" : "황금", "point" : 10, - "stock" : 10, + "maxCount" : 10, "startAt" : "2023-02-01", "openAt" : "2023-01-01" }
@@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/35 HTTP/1.1
+
DELETE /admins/coupons/34 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/23 HTTP/1.1
+
GET /coupons/22 HTTP/1.1
 Host: localhost:8080
@@ -540,15 +540,15 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 198 +Content-Length: 201 { - "id" : 23, - "adminName" : "1admin", + "id" : 22, + "adminName" : "ID : 1", "name" : "couponName", "description" : "", "point" : 10, - "stock" : 100, + "maxCount" : 100, "type" : "MORNING", "startAt" : "2023-02-01", "openAt" : "2023-01-01" @@ -590,15 +590,15 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 199 +Content-Length: 202 [ { - "id" : 24, - "adminName" : "1admin", + "id" : 23, + "adminName" : "ID : 1", "name" : "coupon1", "description" : "", "point" : 10, - "stock" : 100, + "maxCount" : 100, "type" : "MORNING", "startAt" : "2023-03-01", "openAt" : "2023-01-01" diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index d56f528e..57c06cd4 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -461,7 +461,7 @@

콕 찌르기 알림

요청

-
GET /notifications/rooms/4/members/4 HTTP/1.1
+
GET /notifications/rooms/1/members/2 HTTP/1.1
 Host: localhost:8080
diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index ea6a7b48..bd739907 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -50,14 +50,7 @@ class CouponManageServiceTest { @Mock ClockHolder clockHolder; - @DisplayName("쿠폰 관리 인덱스를 성공적으로 초기화한다.") - @Test - void init_success() { - // When & Then - assertThatNoException().isThrownBy(() -> couponManageService.init()); - } - - @DisplayName("10명의 사용자가 쿠폰 발행을 성공적으로 한.") + @DisplayName("10명의 사용자가 쿠폰 발행을 성공적으로 한다.") @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") @ParameterizedTest void issue_all_success(Set values) { @@ -66,9 +59,9 @@ void issue_all_success(Set values) { given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); - given(couponManageRepository.rankQueue(any(String.class), any(Long.class))).willReturn(coupon.getStock()); given(couponManageRepository.rangeQueue(any(String.class), any(long.class), any(long.class))) .willReturn(values); + given(couponManageRepository.getCouponCount(any(String.class))).willReturn(coupon.getMaxCount() - 1); // When couponManageService.issue(); @@ -100,22 +93,22 @@ void issue_notStartAt() { @DisplayName("해당 쿠폰은 재고가 마감된 쿠폰이다.") @MethodSource("com.moabam.support.fixture.CouponFixture#provideValues_Long") @ParameterizedTest - void issue_stockEnd(Set values) { + 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.rankQueue(any(String.class), any(Long.class))).willReturn(coupon.getStock() + 1); - given(couponManageRepository.rangeQueue(any(String.class), any(long.class), any(long.class))) - .willReturn(values); + given(couponManageRepository.getCouponCount(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(notificationService, times(10)) + 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)); } @@ -126,7 +119,9 @@ void registerQueue_success() { Coupon coupon = CouponFixture.coupon(); given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(true); + 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); @@ -140,7 +135,8 @@ void registerQueue_success() { void registerQueue_No_BadRequestException() { // Given given(clockHolder.date()).willReturn(LocalDate.now()); - given(couponRepository.existsByNameAndStartAt(any(String.class), any(LocalDate.class))).willReturn(false); + given(couponRepository.findByNameAndStartAt(any(String.class), any(LocalDate.class))) + .willReturn(Optional.empty()); // When & Then assertThatThrownBy(() -> couponManageService.registerQueue("couponName", 1L)) diff --git a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java index 449fb849..a8f37413 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -220,7 +220,7 @@ void getById_success() { // Then assertThat(actual.point()).isEqualTo(coupon.getPoint()); - assertThat(actual.stock()).isEqualTo(coupon.getStock()); + assertThat(actual.maxCount()).isEqualTo(coupon.getMaxCount()); } @DisplayName("존재하지 않는 쿠폰을 조회한다. - NotFoundException") diff --git a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java index 47dcb2d4..caca0818 100644 --- a/src/test/java/com/moabam/api/domain/coupon/CouponTest.java +++ b/src/test/java/com/moabam/api/domain/coupon/CouponTest.java @@ -25,7 +25,7 @@ void coupon_success() { .name("couponName") .point(10) .type(CouponType.MORNING) - .stock(100) + .maxCount(100) .startAt(startAt) .openAt(openAt) .adminId(1L) @@ -35,7 +35,7 @@ void coupon_success() { assertThat(actual.getName()).isEqualTo("couponName"); assertThat(actual.getDescription()).isBlank(); assertThat(actual.getPoint()).isEqualTo(10); - assertThat(actual.getStock()).isEqualTo(100); + assertThat(actual.getMaxCount()).isEqualTo(100); assertThat(actual.getType()).isEqualTo(CouponType.MORNING); assertThat(actual.getStartAt()).isEqualTo(startAt); assertThat(actual.getOpenAt()).isEqualTo(openAt); diff --git a/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java index c0566697..7be74a87 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/ValueRedisRepositoryTest.java @@ -78,7 +78,7 @@ void delete_success() { @Test void increment_success() { // When - Long actual = valueRedisRepository.increment(stockKey); + Long actual = valueRedisRepository.increment(stockKey, 1); // Then assertThat(actual).isEqualTo(1L); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 757f79c3..1193a93f 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -419,27 +419,6 @@ void use_BadRequestException() throws Exception { .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_DISCOUNT_COUPON.getMessage())); } - @WithMember - @DisplayName("POST - 쿠폰 발급 요청을 성공적으로 한다. - Void") - @Test - void registerQueue_success() throws Exception { - // Given - Coupon couponFixture = CouponFixture.coupon(); - Coupon coupon = couponRepository.save(couponFixture); - - given(clockHolder.date()).willReturn(LocalDate.of(2023, 2, 1)); - willDoNothing().given(zSetRedisRepository).addIfAbsent(anyString(), anyLong(), anyDouble(), anyInt()); - - // 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 - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not found) - BadRequestException") @Test diff --git a/src/test/java/com/moabam/support/fixture/CouponFixture.java b/src/test/java/com/moabam/support/fixture/CouponFixture.java index c517b907..6cff038a 100644 --- a/src/test/java/com/moabam/support/fixture/CouponFixture.java +++ b/src/test/java/com/moabam/support/fixture/CouponFixture.java @@ -23,7 +23,7 @@ public static Coupon coupon() { .name("couponName") .point(1000) .type(CouponType.MORNING) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) @@ -35,19 +35,19 @@ public static Coupon coupon(String name, int startAt) { .name(name) .point(10) .type(CouponType.MORNING) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, startAt, 1)) .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) .build(); } - public static Coupon coupon(int point, int stock) { + public static Coupon coupon(int point, int maxCount) { return Coupon.builder() .name("couponName") .point(point) .type(CouponType.MORNING) - .stock(stock) + .maxCount(maxCount) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) @@ -59,7 +59,7 @@ public static Coupon coupon(CouponType couponType, int point) { .name("couponName") .point(point) .type(couponType) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) @@ -71,7 +71,7 @@ public static Coupon coupon(String name, int startMonth, int openMonth) { .name(name) .point(10) .type(CouponType.MORNING) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, startMonth, 1)) .openAt(LocalDate.of(2023, openMonth, 1)) .adminId(1L) @@ -83,7 +83,7 @@ public static Coupon discount1000Coupon() { .name(DISCOUNT_1000_COUPON_NAME) .point(1000) .type(CouponType.DISCOUNT) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) .adminId(1L) @@ -95,7 +95,7 @@ public static Coupon discount10000Coupon() { .name(DISCOUNT_10000_COUPON_NAME) .point(10000) .type(CouponType.DISCOUNT) - .stock(100) + .maxCount(100) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 2, 1)) .adminId(1L) @@ -108,7 +108,7 @@ public static CreateCouponRequest createCouponRequest() { .description("coupon description") .point(10) .type(CouponType.GOLDEN.getName()) - .stock(10) + .maxCount(10) .startAt(LocalDate.of(2023, 2, 1)) .openAt(LocalDate.of(2023, 1, 1)) .build(); @@ -120,7 +120,7 @@ public static CreateCouponRequest createCouponRequest(String couponType, int sta .description("coupon description") .point(10) .type(couponType) - .stock(10) + .maxCount(10) .startAt(LocalDate.of(2023, startMonth, 1)) .openAt(LocalDate.of(2023, openMonth, 1)) .build(); diff --git a/src/test/java/com/moabam/support/snippet/CouponSnippet.java b/src/test/java/com/moabam/support/snippet/CouponSnippet.java index e1e4697f..71b36dc5 100644 --- a/src/test/java/com/moabam/support/snippet/CouponSnippet.java +++ b/src/test/java/com/moabam/support/snippet/CouponSnippet.java @@ -14,7 +14,7 @@ public final class CouponSnippet { fieldWithPath("description").type(STRING).description("쿠폰 간단 소개 (NULL 가능)"), fieldWithPath("type").type(STRING).description("쿠폰 종류 (아침, 저녁, 황금, 할인)"), fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), - fieldWithPath("stock").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)") ); @@ -25,9 +25,8 @@ public final class CouponSnippet { fieldWithPath("name").type(STRING).description("쿠폰명"), fieldWithPath("description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), - fieldWithPath("stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), - fieldWithPath("type").type(STRING) - .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), + 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)") ); @@ -43,9 +42,8 @@ public final class CouponSnippet { fieldWithPath("[].name").type(STRING).description("쿠폰명"), fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), - fieldWithPath("[].stock").type(NUMBER).description("쿠폰을 발급 받을 수 있는 수"), - fieldWithPath("[].type").type(STRING) - .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)"), + 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 index 8e1b0c1e..d4a887cb 100644 --- a/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java +++ b/src/test/java/com/moabam/support/snippet/CouponWalletSnippet.java @@ -13,7 +13,6 @@ public final class CouponWalletSnippet { fieldWithPath("[].name").type(STRING).description("쿠폰명"), fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"), fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"), - fieldWithPath("[].type").type(STRING) - .description("쿠폰 종류 (MORNING_COUPON, NIGHT_COUPON, GOLDEN_COUPON, DISCOUNT_COUPON)") + fieldWithPath("[].type").type(STRING).description("쿠폰 종류 (MORNING, NIGHT, GOLDEN, DISCOUNT)") ); } From 7f9dc7c069d8d83b594fb61c8463997ca0918004 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 1 Dec 2023 03:20:01 +0900 Subject: [PATCH 144/185] =?UTF-8?q?hotfix:=20nginx=20client=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=ED=81=AC=EA=B8=B0=20=EC=A0=9C=ED=95=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/nginx/nginx.conf | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/nginx/nginx.conf b/infra/nginx/nginx.conf index 7b4e05a7..73220f14 100644 --- a/infra/nginx/nginx.conf +++ b/infra/nginx/nginx.conf @@ -9,6 +9,7 @@ http { include mime.types; default_type application/octet-stream; sendfile on; + client_max_body_size 10M; send_timeout 15s; resolver_timeout 5s; From aa8c32f77de8ac4cc36a16b89b2b8cecf4c659e0 Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 12:33:41 +0900 Subject: [PATCH 145/185] =?UTF-8?q?hotfix:=20=EC=BF=A0=ED=8F=B0=ED=81=90?= =?UTF-8?q?=20=EB=B9=84=EC=96=B4=EC=9E=88=EC=9D=84=20=EC=8B=9C,=20?= =?UTF-8?q?=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/coupon/CouponManageService.java | 7 ++++++- .../coupon/repository/CouponManageRepository.java | 15 ++++++++++----- src/main/resources/static/docs/coupon.html | 8 ++++---- .../coupon/CouponManageServiceTest.java | 4 ++-- .../api/presentation/CouponControllerTest.java | 3 --- 5 files changed, 22 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index bf3d5934..67e3bac7 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -49,7 +49,7 @@ public void issue() { Coupon coupon = optionalCoupon.get(); String couponName = coupon.getName(); int maxCount = coupon.getMaxCount(); - int currentCount = couponManageRepository.getCouponCount(couponName); + int currentCount = couponManageRepository.getCount(couponName); if (maxCount <= currentCount) { return; @@ -57,6 +57,10 @@ public void issue() { 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); @@ -73,6 +77,7 @@ public void registerQueue(String couponName, Long memberId) { public void deleteQueue(String couponName) { couponManageRepository.deleteQueue(couponName); + couponManageRepository.deleteCount(couponName); } private void validateRegisterQueue(String couponName, Long memberId) { 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 index 62864dde..44e2c150 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -41,12 +41,12 @@ public Set rangeQueue(String couponName, long start, long end) { } public boolean hasValue(String couponName, Long memberId) { - return Objects.nonNull(zSetRedisRepository.score(couponName, memberId)); + return Objects.nonNull(zSetRedisRepository.score(requireNonNull(couponName), memberId)); } public int sizeQueue(String couponName) { return zSetRedisRepository - .size(couponName) + .size(requireNonNull(couponName)) .intValue(); } @@ -56,17 +56,22 @@ public int rankQueue(String couponName, Long memberId) { .intValue(); } - public int getCouponCount(String couponName) { - String couponCountKey = String.format(COUPON_COUNT_KEY, couponName); + public int getCount(String couponName) { + String couponCountKey = String.format(COUPON_COUNT_KEY, requireNonNull(couponName)); return Integer.parseInt(valueRedisRepository.get(couponCountKey)); } public void increase(String couponName, long count) { - String couponCountKey = String.format(COUPON_COUNT_KEY, couponName); + 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/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index f677031b..d7435ce2 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/34 HTTP/1.1
+
DELETE /admins/coupons/33 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/22 HTTP/1.1
+
GET /coupons/21 HTTP/1.1
 Host: localhost:8080
@@ -543,7 +543,7 @@

응답

Content-Length: 201 { - "id" : 22, + "id" : 21, "adminName" : "ID : 1", "name" : "couponName", "description" : "", @@ -593,7 +593,7 @@

응답

Content-Length: 202 [ { - "id" : 23, + "id" : 22, "adminName" : "ID : 1", "name" : "coupon1", "description" : "", diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index bd739907..baa32d07 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -61,7 +61,7 @@ void issue_all_success(Set values) { 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.getCouponCount(any(String.class))).willReturn(coupon.getMaxCount() - 1); + given(couponManageRepository.getCount(any(String.class))).willReturn(coupon.getMaxCount() - 1); // When couponManageService.issue(); @@ -99,7 +99,7 @@ void issue_stockEnd() { given(clockHolder.date()).willReturn(LocalDate.now()); given(couponRepository.findByStartAt(any(LocalDate.class))).willReturn(Optional.of(coupon)); - given(couponManageRepository.getCouponCount(any(String.class))).willReturn(coupon.getMaxCount()); + given(couponManageRepository.getCount(any(String.class))).willReturn(coupon.getMaxCount()); // When couponManageService.issue(); diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index 1193a93f..fd968940 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -447,9 +447,6 @@ void registerQueue_Zero_StartAt_BadRequestException() throws Exception { @Test void registerQueue_Not_StartAt_BadRequestException() throws Exception { // Given - Coupon couponFixture = CouponFixture.coupon(); - couponRepository.save(couponFixture); - given(clockHolder.date()).willReturn(LocalDate.of(2022, 2, 1)); // When & Then From ed0a711da4b08d6b9647adc4907d454eea89ed31 Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 16:01:58 +0900 Subject: [PATCH 146/185] =?UTF-8?q?hotfix:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=ED=9A=9F=EC=88=98=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../coupon/CouponManageService.java | 5 +- .../api/application/coupon/CouponService.java | 2 +- .../repository/CouponManageRepository.java | 8 ++- src/main/resources/static/docs/coupon.html | 14 ++--- .../coupon/CouponManageServiceTest.java | 8 +-- .../application/coupon/CouponServiceTest.java | 2 +- .../presentation/CouponControllerTest.java | 58 ++++++++++++++----- 7 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java index 67e3bac7..3cda6f9a 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponManageService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponManageService.java @@ -16,6 +16,7 @@ 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; @@ -75,7 +76,7 @@ public void registerQueue(String couponName, Long memberId) { couponManageRepository.addIfAbsentQueue(couponName, memberId, registerTime); } - public void deleteQueue(String couponName) { + public void delete(String couponName) { couponManageRepository.deleteQueue(couponName); couponManageRepository.deleteCount(couponName); } @@ -83,7 +84,7 @@ public void deleteQueue(String couponName) { private void validateRegisterQueue(String couponName, Long memberId) { LocalDate now = clockHolder.date(); Coupon coupon = couponRepository.findByNameAndStartAt(couponName, now) - .orElseThrow(() -> new BadRequestException(ErrorMessage.INVALID_COUPON_PERIOD)); + .orElseThrow(() -> new NotFoundException(ErrorMessage.INVALID_COUPON_PERIOD)); if (couponManageRepository.hasValue(couponName, memberId)) { throw new ConflictException(ErrorMessage.CONFLICT_COUPON_ISSUE); diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 43b289fb..3a765dff 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -60,7 +60,7 @@ public void delete(Long couponId, Role role) { .orElseThrow(() -> new NotFoundException(ErrorMessage.NOT_FOUND_COUPON)); couponRepository.delete(coupon); - couponManageService.deleteQueue(coupon.getName()); + couponManageService.delete(coupon.getName()); } @Transactional 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 index 44e2c150..0381f236 100644 --- a/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java +++ b/src/main/java/com/moabam/api/domain/coupon/repository/CouponManageRepository.java @@ -58,7 +58,13 @@ public int rankQueue(String couponName, Long memberId) { public int getCount(String couponName) { String couponCountKey = String.format(COUPON_COUNT_KEY, requireNonNull(couponName)); - return Integer.parseInt(valueRedisRepository.get(couponCountKey)); + String count = valueRedisRepository.get(couponCountKey); + + if (isNull(count)) { + return 0; + } + + return Integer.parseInt(count); } public void increase(String couponName, long count) { diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index d7435ce2..af8593f3 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -498,7 +498,7 @@

쿠폰 삭제

요청

-
DELETE /admins/coupons/33 HTTP/1.1
+
DELETE /admins/coupons/36 HTTP/1.1
 Host: localhost:8080
@@ -526,7 +526,7 @@

특정 쿠폰 조회

요청

-
GET /coupons/21 HTTP/1.1
+
GET /coupons/24 HTTP/1.1
 Host: localhost:8080
@@ -543,7 +543,7 @@

응답

Content-Length: 201 { - "id" : 21, + "id" : 24, "adminName" : "ID : 1", "name" : "couponName", "description" : "", @@ -593,7 +593,7 @@

응답

Content-Length: 202 [ { - "id" : 22, + "id" : 25, "adminName" : "ID : 1", "name" : "coupon1", "description" : "", @@ -630,17 +630,17 @@

요청

응답

-
HTTP/1.1 400 Bad Request
+
HTTP/1.1 409 Conflict
 Access-Control-Allow-Origin:
 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: 64
+Content-Length: 63
 
 {
-  "message" : "쿠폰 발급 가능 기간이 아닙니다."
+  "message" : "이미 쿠폰 발급에 성공했습니다!"
 }
diff --git a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java index baa32d07..d209cdfe 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponManageServiceTest.java @@ -24,7 +24,7 @@ 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.NotFoundException; import com.moabam.global.error.model.ErrorMessage; import com.moabam.support.common.FilterProcessExtension; import com.moabam.support.fixture.CouponFixture; @@ -140,7 +140,7 @@ void registerQueue_No_BadRequestException() { // When & Then assertThatThrownBy(() -> couponManageService.registerQueue("couponName", 1L)) - .isInstanceOf(BadRequestException.class) + .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.INVALID_COUPON_PERIOD.getMessage()); } @@ -151,7 +151,7 @@ void deleteQueue_success() { String couponName = "couponName"; // When - couponManageService.deleteQueue(couponName); + couponManageService.delete(couponName); // Then verify(couponManageRepository).deleteQueue(couponName); @@ -166,7 +166,7 @@ void deleteQueue_NullPointerException() { .deleteQueue(any(String.class)); // When & Then - assertThatThrownBy(() -> couponManageService.deleteQueue("null")) + 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 index a8f37413..b44d6f8c 100644 --- a/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java +++ b/src/test/java/com/moabam/api/application/coupon/CouponServiceTest.java @@ -183,7 +183,7 @@ void delete_success() { // Then verify(couponRepository).delete(coupon); - verify(couponManageService).deleteQueue(any(String.class)); + verify(couponManageService).delete(any(String.class)); } @DisplayName("권한 없는 사용자가 쿠폰을 삭제한다. - NotFoundException") diff --git a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java index fd968940..65f25723 100644 --- a/src/test/java/com/moabam/api/presentation/CouponControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/CouponControllerTest.java @@ -35,6 +35,7 @@ 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; @@ -73,6 +74,9 @@ class CouponControllerTest extends WithoutFilterSupporter { @MockBean ZSetRedisRepository zSetRedisRepository; + @MockBean + ValueRedisRepository valueRedisRepository; + @WithMember(role = Role.ADMIN) @DisplayName("POST - 쿠폰을 성공적으로 발행한다. - Void") @Test @@ -420,9 +424,31 @@ void use_BadRequestException() throws Exception { } @WithMember - @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not found) - BadRequestException") + @DisplayName("POST - 쿠폰 발급을 성공적으로 한다. - Void") @Test - void registerQueue_Zero_StartAt_BadRequestException() throws Exception { + 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); @@ -437,39 +463,45 @@ void registerQueue_Zero_StartAt_BadRequestException() throws Exception { preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), ErrorSnippet.ERROR_MESSAGE_RESPONSE)) - .andExpect(status().isBadRequest()) + .andExpect(status().isNotFound()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); } @WithMember - @DisplayName("POST - 발급 가능 날짜가 아닌 쿠폰에 발급 요청을 한다. (Not equals) - BadRequestException") + @DisplayName("POST - 동일한 쿠폰 이벤트에 중복으로 요청한다. - ConflictException") @Test - void registerQueue_Not_StartAt_BadRequestException() throws Exception { + void registerQueue_ConflictException() throws Exception { // Given - given(clockHolder.date()).willReturn(LocalDate.of(2022, 2, 1)); + 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", "not start couponName")) + .param("couponName", coupon.getName())) .andDo(print()) .andDo(document("coupons", preprocessRequest(prettyPrint()), preprocessResponse(prettyPrint()), ErrorSnippet.ERROR_MESSAGE_RESPONSE)) - .andExpect(status().isBadRequest()) + .andExpect(status().isConflict()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + .andExpect(jsonPath("$.message").value(ErrorMessage.CONFLICT_COUPON_ISSUE.getMessage())); } @WithMember - @DisplayName("POST - 존재하지 않는 쿠폰에 발급 요청을 한다. - NotFoundException") + @DisplayName("POST - 선착순 이벤트가 마감된 쿠폰에 발급 요청을 한다. - BadRequestException") @Test - void registerQueue_NotFoundException() throws Exception { + void registerQueue_BadRequestException() throws Exception { // Given - Coupon coupon = CouponFixture.coupon("Not found couponName", 2, 1); + 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") @@ -481,6 +513,6 @@ void registerQueue_NotFoundException() throws Exception { ErrorSnippet.ERROR_MESSAGE_RESPONSE)) .andExpect(status().isBadRequest()) .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_PERIOD.getMessage())); + .andExpect(jsonPath("$.message").value(ErrorMessage.INVALID_COUPON_STOCK_END.getMessage())); } } From 39705ed84886e7d472f449a7b1c5c2694105db4c Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 16:28:33 +0900 Subject: [PATCH 147/185] =?UTF-8?q?hotfix:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 +++ src/docs/asciidoc/index.adoc | 4 ++-- .../moabam/global/config/SwaggerConfig.java | 18 ++++++++++++++++++ .../com/moabam/global/config/WebConfig.java | 2 ++ 4 files changed, 25 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/moabam/global/config/SwaggerConfig.java diff --git a/build.gradle b/build.gradle index 2f40eeda..2f171866 100644 --- a/build.gradle +++ b/build.gradle @@ -104,6 +104,9 @@ dependencies { // webflux implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } tasks.named('test') { diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 90fb923e..ea559aeb 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -17,8 +17,8 @@ [cols="2,5,3"] |==== |환경 |DNS |비고 -|개발(dev) | link:[dev-api.moabam.com] | -|운영(prod) | link:[api.moabam.com] | +|개발(dev) | link:[dev.moabam.com] | +|운영(prod) | link:[www.moabam.com] | |==== [NOTE] diff --git a/src/main/java/com/moabam/global/config/SwaggerConfig.java b/src/main/java/com/moabam/global/config/SwaggerConfig.java new file mode 100644 index 00000000..8fa04fb5 --- /dev/null +++ b/src/main/java/com/moabam/global/config/SwaggerConfig.java @@ -0,0 +1,18 @@ +package com.moabam.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; + +@Configuration +public class SwaggerConfig { + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(new Info() + .title("모아밤 프로젝트 API")); + } +} diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index 387714cb..e4fc7d75 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -42,6 +42,8 @@ public PathResolver pathResolver() { 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(); From ee34964fc2ebc499554a7872555f074a7e3f2c97 Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Fri, 1 Dec 2023 16:41:36 +0900 Subject: [PATCH 148/185] =?UTF-8?q?hotfix:=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moabam/global/config/SwaggerConfig.java | 18 ------------------ src/main/resources/static/docs/index.html | 6 +++--- 2 files changed, 3 insertions(+), 21 deletions(-) delete mode 100644 src/main/java/com/moabam/global/config/SwaggerConfig.java diff --git a/src/main/java/com/moabam/global/config/SwaggerConfig.java b/src/main/java/com/moabam/global/config/SwaggerConfig.java deleted file mode 100644 index 8fa04fb5..00000000 --- a/src/main/java/com/moabam/global/config/SwaggerConfig.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.moabam.global.config; - -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.info.Info; - -@Configuration -public class SwaggerConfig { - - @Bean - public OpenAPI openAPI() { - return new OpenAPI() - .info(new Info() - .title("모아밤 프로젝트 API")); - } -} diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 62f80fd6..154f140e 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -484,12 +484,12 @@

1.1. API

개발(dev)

-

dev-api.moabam.com

+

dev.moabam.com

운영(prod)

-

api.moabam.com

+

www.moabam.com

@@ -616,7 +616,7 @@

From ae751fbeba4950c3eeb739f7ab908db4e92fe754 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Fri, 1 Dec 2023 19:25:18 +0900 Subject: [PATCH 149/185] =?UTF-8?q?feat:=20=EC=98=88=EC=99=B8=20=EB=B0=9C?= =?UTF-8?q?=EC=83=9D=20=EC=8B=9C=20=EC=8A=AC=EB=9E=99=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20(#215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: 기본 상점 상품 쿼리 수정 * chore: slack api client 의존성 추가 * feat: 예외 발생 시 슬랙 연동 구현 * chore: slack webhook url config 추가 * fix: build 오류 해결 --- build.gradle | 3 + infra/mysql/initdb.d/item-data.sql | 12 +-- .../slack/SlackMessageFactory.java | 74 +++++++++++++++++++ .../infrastructure/slack/SlackService.java | 26 +++++++ .../moabam/global/common/util/DateUtils.java | 2 +- .../global/common/util/GlobalConstant.java | 3 - .../com/moabam/global/config/SlackConfig.java | 19 +++++ .../error/handler/GlobalExceptionHandler.java | 9 ++- .../error/handler/SlackExceptionHandler.java | 24 ++++++ src/main/resources/config | 2 +- .../ranking/RankingServiceTest.java | 6 ++ .../presentation/RankingControllerTest.java | 6 ++ src/test/resources/application.yml | 5 ++ 13 files changed, 176 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java create mode 100644 src/main/java/com/moabam/api/infrastructure/slack/SlackService.java create mode 100644 src/main/java/com/moabam/global/config/SlackConfig.java create mode 100644 src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java diff --git a/build.gradle b/build.gradle index 2f171866..507516ff 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,9 @@ dependencies { // webflux implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Slack Webhook + implementation "net.gpedro.integrations.slack:slack-webhook:1.4.0" + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } diff --git a/infra/mysql/initdb.d/item-data.sql b/infra/mysql/initdb.d/item-data.sql index 7243750c..568e8f0f 100644 --- a/infra/mysql/initdb.d/item-data.sql +++ b/infra/mysql/initdb.d/item-data.sql @@ -38,11 +38,11 @@ insert into item (type, category, name, awake_image, sleep_image, bug_price, gol 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 (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x5', 3000, 5, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 5', 3000, 5, current_time()); -insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x10', 7000, 10, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 15', 7000, 15, current_time()); -insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x25', 9900, 25, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 25', 9900, 25, current_time()); 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/global/common/util/DateUtils.java b/src/main/java/com/moabam/global/common/util/DateUtils.java index 38fa97b4..33e896fa 100644 --- a/src/main/java/com/moabam/global/common/util/DateUtils.java +++ b/src/main/java/com/moabam/global/common/util/DateUtils.java @@ -9,7 +9,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class DateUtils { - private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + 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/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 259c3d13..7b62d447 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -7,8 +7,6 @@ public class GlobalConstant { public static final String BLANK = ""; - public static final String COMMA = ","; - public static final String UNDER_BAR = "_"; public static final String DELIMITER = "/"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; @@ -19,6 +17,5 @@ public class GlobalConstant { public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; - public static final int DEFAULT_SKIN_SIZE = 2; public static final String IMAGE_EXTENSION = ".png"; } 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/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 4d68a475..1b2c6540 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -1,8 +1,6 @@ package com.moabam.global.error.handler; -import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_FIELD; -import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_VALUE_TYPE_FORMAT; -import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE_SIZE; +import static com.moabam.global.error.model.ErrorMessage.*; import java.util.HashMap; import java.util.List; @@ -62,7 +60,10 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - @ExceptionHandler({FcmException.class, TossPaymentException.class}) + @ExceptionHandler({ + FcmException.class, + TossPaymentException.class + }) protected ErrorResponse handleFcmException(MoabamException moabamException) { return new ErrorResponse(moabamException.getMessage(), null); } 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..3751dc5f --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java @@ -0,0 +1,24 @@ +package com.moabam.global.error.handler; + +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.moabam.api.infrastructure.slack.SlackService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestControllerAdvice +@Profile({"dev", "prod"}) +@RequiredArgsConstructor +public class SlackExceptionHandler { + + private final SlackService slackService; + + @ExceptionHandler(Exception.class) + void handleException(HttpServletRequest request, Exception exception) throws Exception { + slackService.send(request, exception); + throw exception; + } +} diff --git a/src/main/resources/config b/src/main/resources/config index c9c58dc4..296e5a5e 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit c9c58dc4c9fbcd88ca2ae51e09b883e296bd3db9 +Subproject commit 296e5a5e54dca447e6230e5538bbb96772221052 diff --git a/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java index d246c5df..1dbb1843 100644 --- a/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java +++ b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java @@ -6,6 +6,7 @@ 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; @@ -38,6 +39,11 @@ public class RankingServiceTest { @Autowired RankingService rankingService; + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + @DisplayName("redis에 추가") @Nested class Add { diff --git a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java index 9e124cb2..f9b866d4 100644 --- a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java @@ -6,6 +6,7 @@ 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; @@ -40,6 +41,11 @@ class RankingControllerTest extends WithoutFilterSupporter { @Autowired RedisTemplate redisTemplate; + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + @DisplayName("") @WithMember @Test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8090fb64..976ad494 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -77,3 +77,8 @@ payment: toss: base-url: "https://api.tosspayments.com" secret-key: "test_sk_4yKeq5bgrpWk4XYdDoBxVGX0lzW6:" + +# Webhook +webhook: + slack: + url: test From 6eec958d21d10f3b3fc4605c5b0787126ebf2124 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Fri, 1 Dec 2023 22:01:18 +0900 Subject: [PATCH 150/185] =?UTF-8?q?fix:=20=EB=B0=A9=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=A3=A8=ED=8B=B4=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EC=A0=9C=EC=99=B8=20(#217)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/moabam/api/application/room/RoomService.java | 6 ------ .../com/moabam/api/dto/room/ModifyRoomRequest.java | 5 ----- .../moabam/api/presentation/RoomControllerTest.java | 12 ++---------- 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 63179715..d79c8db8 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -75,12 +75,6 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR room.changePassword(modifyRoomRequest.password()); room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); - - List routines = routineRepository.findAllByRoomId(roomId); - routineRepository.deleteAll(routines); - - List newRoutines = RoutineMapper.toRoutineEntities(room, modifyRoomRequest.routines()); - routineRepository.saveAll(newRoutines); } @Transactional diff --git a/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java index 5d5fd558..28d5cdb1 100644 --- a/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java +++ b/src/main/java/com/moabam/api/dto/room/ModifyRoomRequest.java @@ -1,19 +1,14 @@ package com.moabam.api.dto.room; -import java.util.List; - import org.hibernate.validator.constraints.Length; import org.hibernate.validator.constraints.Range; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; -import jakarta.validation.constraints.Size; public record ModifyRoomRequest( @NotBlank @Length(max = 20) String title, @Length(max = 100, message = "방 공지의 길이 100자 이하여야 합니다.") String announcement, - @NotNull @Size(min = 1, max = 4) List routines, @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/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 765d6f38..a0b0eb99 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -299,15 +299,11 @@ void modify_room_success() throws Exception { Participant participant = RoomFixture.participant(room, 1L); participant.enableManager(); - List newRoutines = new ArrayList<>(); - newRoutines.add("물 마시기"); - newRoutines.add("코테 풀기"); - roomRepository.save(room); routineRepository.saveAll(routines); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "공지공지", newRoutines, "4567", 10, 7); + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "공지공지", "4567", 10, 7); String json = objectMapper.writeValueAsString(modifyRoomRequest); // expected @@ -343,13 +339,9 @@ void unauthorized_modify_room_fail() throws Exception { Participant participant = RoomFixture.participant(room, 1L); - List routines = new ArrayList<>(); - routines.add("물 마시기"); - routines.add("코테 풀기"); - roomRepository.save(room); participantRepository.save(participant); - ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "방 공지", routines, "1234", 9, 7); + ModifyRoomRequest modifyRoomRequest = new ModifyRoomRequest("수정할 방임!", "방 공지", "1234", 9, 7); String json = objectMapper.writeValueAsString(modifyRoomRequest); String message = "{\"message\":\"방장이 아닌 사용자는 방을 수정할 수 없습니다.\"}"; From cc4056e10887274b20b850bc575527c872775a5a Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Fri, 1 Dec 2023 22:06:56 +0900 Subject: [PATCH 151/185] feat: admin login (#216) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 * fix: ObjectMapper에러 수정 * fix: objectMapper 삭제 추가 * feat: 어드민 서비스 로그인 기능 추가 * refactor: 어드민 config 업데이트 * fix: test application.yml 수정 * test: stub에서의 타입 오류 해결 * style: 변수면 변경 --------- Co-authored-by: Kim Heebin --- .../admin/application/admin/AdminMapper.java | 12 ++++ .../admin/application/admin/AdminService.java | 56 +++++++++++++++++++ .../com/moabam/admin/domain/admin/Admin.java | 53 ++++++++++++++++++ .../admin/domain/admin/AdminRepository.java | 10 ++++ .../presentation/admin/AdminController.java | 39 +++++++++++++ .../auth/AuthorizationService.java | 14 +++-- .../application/auth/mapper/AuthMapper.java | 12 ++++ .../auth/mapper/AuthorizationMapper.java | 5 +- .../moabam/global/auth/filter/CorsFilter.java | 21 ++++--- .../global/config/AllowOriginConfig.java | 12 ++++ .../com/moabam/global/config/OAuthConfig.java | 3 +- .../com/moabam/global/config/WebConfig.java | 1 + .../handler/RestTemplateResponseHandler.java | 20 ++++++- .../global/error/model/ErrorMessage.java | 1 + src/main/resources/config | 2 +- .../auth/AuthorizationServiceTest.java | 12 ++-- .../MemberAuthorizeControllerTest.java | 2 +- .../common/WithoutFilterSupporter.java | 8 ++- src/test/resources/application.yml | 8 ++- 19 files changed, 265 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/moabam/admin/application/admin/AdminMapper.java create mode 100644 src/main/java/com/moabam/admin/application/admin/AdminService.java create mode 100644 src/main/java/com/moabam/admin/domain/admin/Admin.java create mode 100644 src/main/java/com/moabam/admin/domain/admin/AdminRepository.java create mode 100644 src/main/java/com/moabam/admin/presentation/admin/AdminController.java create mode 100644 src/main/java/com/moabam/global/config/AllowOriginConfig.java 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..d9cf0b35 --- /dev/null +++ b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java @@ -0,0 +1,12 @@ +package com.moabam.admin.application.admin; + +import com.moabam.admin.domain.admin.Admin; + +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..892f15cf --- /dev/null +++ b/src/main/java/com/moabam/admin/application/admin/AdminService.java @@ -0,0 +1,56 @@ +package com.moabam.admin.application.admin; + +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.moabam.admin.domain.admin.Admin; +import com.moabam.admin.domain.admin.AdminRepository; +import com.moabam.api.application.auth.AuthorizationService; +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.model.ErrorMessage; + +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminService { + + @Value("${admin}") + private String adminLoginKey; + + private final AuthorizationService authorizationService; + private final AdminRepository adminRepository; + + public void validate(String state) { + if (!adminLoginKey.equals(state)) { + throw new BadRequestException(ErrorMessage.LOGIN_FAILED_ADMIN_KEY); + } + } + + public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse, + AuthorizationTokenInfoResponse authorizationTokenInfoResponse) { + LoginResponse loginResponse = login(authorizationTokenInfoResponse); + authorizationService.issueServiceToken(httpServletResponse, loginResponse.publicClaim()); + + return loginResponse; + } + + 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); + } +} 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..d63cbeff --- /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("'USER'") + 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..8ef4354b --- /dev/null +++ b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java @@ -0,0 +1,39 @@ +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); + + return adminService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse); + } +} diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 34234f94..a2db2e9f 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -56,10 +56,17 @@ public void redirectToLoginPage(HttpServletResponse httpServletResponse) { 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()); + return issueTokenToAuthorizationServer(authorizationCodeResponse.code(), oAuthConfig.provider().redirectUri()); } public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { @@ -182,10 +189,9 @@ private void validAuthorizationGrant(String code) { } } - private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code) { + private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code, String redirectUri) { AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( - oAuthConfig, - code); + oAuthConfig, code, redirectUri); MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest); ResponseEntity authorizationTokenResponse = oauth2AuthorizationServerRequestService.requestAuthorizationServer(oAuthConfig.provider().tokenUri(), 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 index 7b56ae24..0fe48a56 100644 --- a/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java +++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthMapper.java @@ -1,5 +1,6 @@ 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; @@ -22,6 +23,17 @@ public static LoginResponse toLoginResponse(Member member, boolean 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) 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 index cd06461d..3e566153 100644 --- a/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java +++ b/src/main/java/com/moabam/api/application/auth/mapper/AuthorizationMapper.java @@ -23,11 +23,12 @@ public static AuthorizationCodeRequest toAuthorizationCodeRequest(OAuthConfig oA .build(); } - public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig oAuthConfig, String code) { + public static AuthorizationTokenRequest toAuthorizationTokenRequest(OAuthConfig oAuthConfig, String code, + String redirectUri) { return AuthorizationTokenRequest.builder() .grantType(oAuthConfig.client().authorizationGrantType()) .clientId(oAuthConfig.client().clientId()) - .redirectUri(oAuthConfig.provider().redirectUri()) + .redirectUri(redirectUri) .code(code) .clientSecret(oAuthConfig.client().clientSecret()) .build(); diff --git a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java index c113f156..f363c2df 100644 --- a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java @@ -1,14 +1,15 @@ package com.moabam.global.auth.filter; import java.io.IOException; +import java.util.Objects; -import org.springframework.beans.factory.annotation.Value; 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; @@ -31,26 +32,27 @@ public class CorsFilter extends OncePerRequestFilter { private final HandlerExceptionResolver handlerExceptionResolver; - @Value("${allow}") - private String allowOrigin; + private final AllowOriginConfig allowOriginsConfig; @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { + String refer = httpServletRequest.getHeader("referer"); + String origin = secureMatch(refer); try { - if (!secureMatch(httpServletRequest, allowOrigin)) { + if (Objects.isNull(origin)) { throw new UnauthorizedException(ErrorMessage.INVALID_REQUEST_URL); } } catch (UnauthorizedException unauthorizedException) { - log.error("{}, {}", httpServletRequest.getHeader("referer"), allowOrigin); + log.error("{}, {}", httpServletRequest.getHeader("referer"), allowOriginsConfig.origin()); handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, unauthorizedException); return; } - httpServletResponse.setHeader("Access-Control-Allow-Origin", allowOrigin); + 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"); @@ -63,8 +65,11 @@ protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServl filterChain.doFilter(httpServletRequest, httpServletResponse); } - public boolean secureMatch(HttpServletRequest request, String origin) { - return request.getHeader("referer").contains(origin); + public String secureMatch(String refer) { + return allowOriginsConfig.origin().stream() + .filter(refer::contains) + .findFirst() + .orElse(null); } public boolean isOption(String method) { 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..d2ae8db6 --- /dev/null +++ b/src/main/java/com/moabam/global/config/AllowOriginConfig.java @@ -0,0 +1,12 @@ +package com.moabam.global.config; + +import java.util.List; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "allows") +public record AllowOriginConfig( + List origin +) { + +} diff --git a/src/main/java/com/moabam/global/config/OAuthConfig.java b/src/main/java/com/moabam/global/config/OAuthConfig.java index 2a8eb592..4884aacb 100644 --- a/src/main/java/com/moabam/global/config/OAuthConfig.java +++ b/src/main/java/com/moabam/global/config/OAuthConfig.java @@ -26,7 +26,8 @@ public record Provider( String redirectUri, String tokenUri, String tokenInfo, - String unlink + String unlink, + String adminRedirectUri ) { } diff --git a/src/main/java/com/moabam/global/config/WebConfig.java b/src/main/java/com/moabam/global/config/WebConfig.java index e4fc7d75..e5971d20 100644 --- a/src/main/java/com/moabam/global/config/WebConfig.java +++ b/src/main/java/com/moabam/global/config/WebConfig.java @@ -35,6 +35,7 @@ public PathResolver pathResolver() { 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/*"), diff --git a/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java index c234dbf1..d0708d97 100644 --- a/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java +++ b/src/main/java/com/moabam/global/error/handler/RestTemplateResponseHandler.java @@ -1,6 +1,8 @@ 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; @@ -14,7 +16,7 @@ public class RestTemplateResponseHandler implements ResponseErrorHandler { @Override - public boolean hasError(ClientHttpResponse response) { + public boolean hasError(ClientHttpResponse response) throws IOException { try { return response.getStatusCode().isError(); } catch (IOException ioException) { @@ -25,13 +27,29 @@ public boolean hasError(ClientHttpResponse response) { @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); diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index d3c8d114..91502993 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -34,6 +34,7 @@ public enum ErrorMessage { IMAGE_CONVERT_FAIL("이미지 변환을 실패했습니다."), LOGIN_FAILED("로그인에 실패했습니다."), + LOGIN_FAILED_ADMIN_KEY("어드민키가 달라요"), REQUEST_FAILED("네트워크 접근 실패입니다."), GRANT_FAILED("인가 코드 실패"), AUTHENTICATE_FAIL("인증 실패"), diff --git a/src/main/resources/config b/src/main/resources/config index 296e5a5e..b217c205 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 296e5a5e54dca447e6230e5538bbb96772221052 +Subproject commit b217c205120a8b9f25277ec1819c7141bbb5591c diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index b4d2e47d..93f6c5a3 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -89,13 +89,13 @@ public void initParams() { oauthConfig = new OAuthConfig( new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", - "http://tokenInfo/url", "https://deleteRequest/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), + 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, jwtProviderService, tokenRepository, cookieUtils); @@ -167,7 +167,8 @@ void authorization_grant_success() { @Test void token_request_mapping_failBy_code() { // When + Then - Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(oauthConfig, null)) + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(oauthConfig, + null, oauthConfig.provider().redirectUri())) .isInstanceOf(NullPointerException.class); } @@ -175,7 +176,8 @@ void token_request_mapping_failBy_code() { @Test void token_request_mapping_failBy_config() { // When + Then - Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test")) + Assertions.assertThatThrownBy(() -> AuthorizationMapper.toAuthorizationTokenRequest(noOAuthConfig, "Test", + oauthConfig.provider().redirectUri())) .isInstanceOf(NullPointerException.class); } @@ -185,7 +187,7 @@ void token_request_mapping_success() { // Given String code = "Test"; AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( - oauthConfig, code); + oauthConfig, code, oauthConfig.provider().redirectUri()); // When + Then assertThat(authorizationTokenRequest).isNotNull(); diff --git a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java index d0df0151..05c8a83e 100644 --- a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java @@ -89,7 +89,7 @@ void setUp() { RestTemplate restTemplate = restTemplateBuilder.build(); ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); - willReturn(true).given(corsFilter).secureMatch(any(), any()); + willReturn("http://localhost").given(corsFilter).secureMatch(any()); } @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") diff --git a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java index a381738f..18d726cc 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -15,6 +15,7 @@ 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}) @@ -29,10 +30,13 @@ public class WithoutFilterSupporter { @SpyBean private CorsFilter corsFilter; + @SpyBean + private AllowOriginConfig allowOriginConfig; + @BeforeEach void setUpMock() { - willReturn(true) - .given(corsFilter).secureMatch(any(), any()); + willReturn("http://localhost:8080") + .given(corsFilter).secureMatch(any()); willReturn(Optional.of(PathResolver.Path.builder() .uri("/") diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 976ad494..c485b58d 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -62,6 +62,7 @@ oauth2: 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: @@ -70,7 +71,12 @@ token: refresh-expire: 150000 secret-key: testestestestestestestestestesttestestestestestestestestestest -allow: "" +allows: + origin: + - "https://test.com" + - "https://test.com" + +admin: moamoamoabam # Payment payment: From 532c3481a159dd631bf72db134f72e86d613f185 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 1 Dec 2023 22:23:31 +0900 Subject: [PATCH 152/185] =?UTF-8?q?hotfix:=20mysql=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=20init=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/mysql/initdb.d/init.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql index b30d105f..9a64249e 100644 --- a/infra/mysql/initdb.d/init.sql +++ b/infra/mysql/initdb.d/init.sql @@ -1,5 +1,16 @@ 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, From d608d0455cd36e2fa320aa98049ac4de55d6041c Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Fri, 1 Dec 2023 22:39:51 +0900 Subject: [PATCH 153/185] =?UTF-8?q?hotfix:=20config=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index b217c205..77b52691 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit b217c205120a8b9f25277ec1819c7141bbb5591c +Subproject commit 77b52691bc52d2c0506cc039ee8ec21d1292380d From b6723719082d85db24698595e045b9860889685c Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Sat, 2 Dec 2023 00:21:00 +0900 Subject: [PATCH 154/185] =?UTF-8?q?hotfix:=2000=EC=8B=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=ED=83=80=EC=9E=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/moabam/api/application/room/CertificationService.java | 3 ++- .../java/com/moabam/global/common/util/GlobalConstant.java | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index e6a56ab3..94f49a73 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -111,7 +111,8 @@ public Certification findCertification(Long certificationId) { private void validateCertifyTime(LocalDateTime now, int certifyTime) { LocalTime targetTime = LocalTime.of(certifyTime, 0); LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); - if (certifyTime == MIDNIGHT_HOUR) { + + if (certifyTime == MIDNIGHT_HOUR && now.getHour() == ONE_HOUR_BEFORE_MIDNIGHT_HOUR) { targetDateTime = targetDateTime.plusDays(1); } diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 7b62d447..c4d6ce53 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -11,6 +11,7 @@ public class GlobalConstant { 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_BEFORE_MIDNIGHT_HOUR = 23; public static final int ONE_HOUR = 1; public static final int HOURS_IN_A_DAY = 24; public static final int NOT_COMPLETED_RANK = 500; From bd3fb6be2db7e408df83f63d1ace6d351be0238d Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Sat, 2 Dec 2023 20:53:30 +0900 Subject: [PATCH 155/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B8=B0=ED=9A=8D=20=EA=B4=80=EB=A0=A8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(#219)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 방 인증 시간 정각부터 10분까지로 수정 * refactor: 참여자 중 한명 이상이 인증 했을 시 방 시간 수정 못하게 변경 * test: 테스트 코드 작성 --- .../room/CertificationService.java | 10 +++-- .../api/application/room/RoomService.java | 15 ++++++- .../DailyMemberCertificationRepository.java | 2 + .../global/common/util/GlobalConstant.java | 1 - .../global/error/model/ErrorMessage.java | 1 + .../room/CertificationServiceTest.java | 2 +- .../api/presentation/RoomControllerTest.java | 40 +++++++++++++++++++ 7 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index 94f49a73..c34c7be5 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -103,6 +103,11 @@ 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)); @@ -112,14 +117,13 @@ 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() == ONE_HOUR_BEFORE_MIDNIGHT_HOUR) { + if (certifyTime == MIDNIGHT_HOUR && now.getHour() != MIDNIGHT_HOUR) { targetDateTime = targetDateTime.plusDays(1); } - LocalDateTime minusTenMinutes = targetDateTime.minusMinutes(10); LocalDateTime plusTenMinutes = targetDateTime.plusMinutes(10); - if (now.isBefore(minusTenMinutes) || now.isAfter(plusTenMinutes)) { + if (now.isBefore(targetDateTime) || now.isAfter(plusTenMinutes)) { throw new BadRequestException(INVALID_CERTIFY_TIME); } } diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index d79c8db8..54f65ed4 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -25,6 +25,7 @@ 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; @@ -42,7 +43,9 @@ public class RoomService { private final RoutineRepository routineRepository; private final ParticipantRepository participantRepository; private final ParticipantSearchRepository participantSearchRepository; + private final CertificationService certificationService; private final MemberService memberService; + private final ClockHolder clockHolder; @Transactional public Long createRoom(Long memberId, CreateRoomRequest createRoomRequest) { @@ -73,8 +76,12 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR room.changeTitle(modifyRoomRequest.title()); room.changeAnnouncement(modifyRoomRequest.announcement()); room.changePassword(modifyRoomRequest.password()); - room.changeCertifyTime(modifyRoomRequest.certifyTime()); room.changeMaxCount(modifyRoomRequest.maxUserCount()); + + if (room.getCertifyTime() != modifyRoomRequest.certifyTime()) { + validateChangeCertifyTime(roomId); + } + room.changeCertifyTime(modifyRoomRequest.certifyTime()); } @Transactional @@ -160,6 +167,12 @@ public Room findRoom(Long 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)); 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 index 19f61f00..ec267010 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/DailyMemberCertificationRepository.java @@ -10,4 +10,6 @@ public interface DailyMemberCertificationRepository extends JpaRepository Date: Sat, 2 Dec 2023 21:42:51 +0900 Subject: [PATCH 156/185] =?UTF-8?q?=08fix:=20=EC=9D=B8=EC=A6=9D=EB=90=9C?= =?UTF-8?q?=20=EC=B0=B8=EC=97=AC=EC=9E=90=EC=9D=98=20=EB=B0=A9=20=EB=82=98?= =?UTF-8?q?=EA=B0=80=EA=B8=B0=20=ED=9B=84=20=EB=B0=A9=20=EC=A0=95=EB=B3=B4?= =?UTF-8?q?=20=EB=B6=88=EB=9F=AC=EC=98=A4=EA=B8=B0=20=EC=95=88=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#221)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 인증하고 나간 참여자 정보 불러오기 * fix: 인증된 방이 삭제되지 않는 버그 수정 --- .../api/application/room/CertificationService.java | 8 ++++++++ .../com/moabam/api/application/room/RoomService.java | 12 ++++++++++++ .../moabam/api/application/room/SearchService.java | 2 +- .../repository/CertificationsSearchRepository.java | 9 +++++++++ .../room/repository/ParticipantSearchRepository.java | 9 +++++++++ .../com/moabam/global/error/model/ErrorMessage.java | 1 + 6 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java index c34c7be5..6f024e6f 100644 --- a/src/main/java/com/moabam/api/application/room/CertificationService.java +++ b/src/main/java/com/moabam/api/application/room/CertificationService.java @@ -113,6 +113,14 @@ public Certification findCertification(Long certificationId) { .orElseThrow(() -> new NotFoundException(CERTIFICATION_NOT_FOUND)); } + public List findCertifications(List routines) { + return certificationsSearchRepository.findCertificationsByRoutines(routines); + } + + public void deleteCertifications(List certifications) { + certificationRepository.deleteAll(certifications); + } + private void validateCertifyTime(LocalDateTime now, int certifyTime) { LocalTime targetTime = LocalTime.of(certifyTime, 0); LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime); diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 54f65ed4..43b5c281 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -3,6 +3,7 @@ import static com.moabam.api.domain.room.RoomType.*; import static com.moabam.global.error.model.ErrorMessage.*; +import java.time.LocalTime; import java.util.List; import org.apache.commons.lang3.StringUtils; @@ -14,10 +15,12 @@ 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.Certification; 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; @@ -43,6 +46,7 @@ public class RoomService { 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; @@ -118,6 +122,9 @@ public void exitRoom(Long memberId, Long roomId) { } List routines = routineRepository.findAllByRoomId(roomId); + List certifications = certificationService.findCertifications(routines); + + certificationService.deleteCertifications(certifications); routineRepository.deleteAll(routines); roomRepository.delete(room); } @@ -216,5 +223,10 @@ 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 index 5aec7dc1..aafb5bb9 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -260,7 +260,7 @@ private List getTodayCertificateRankResponses(Long List responses = new ArrayList<>(); List certifications = certificationsSearchRepository.findCertifications(roomId, date); - List participants = participantSearchRepository.findParticipantsByRoomId(roomId); + List participants = participantSearchRepository.findAllParticipantsByRoomId(roomId); List members = memberService.getRoomMembers(participants.stream() .map(Participant::getMemberId) .toList()); 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 index 8563245c..1226d03f 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java @@ -15,6 +15,7 @@ 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.Routine; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; @@ -35,6 +36,14 @@ public List findCertifications(Long roomId, LocalDate date) { .fetch(); } + public List findCertificationsByRoutines(List routines) { + return jpaQueryFactory.selectFrom(certification) + .where( + certification.routine.in(routines) + ) + .fetch(); + } + public Optional findDailyMemberCertification(Long memberId, Long roomId, LocalDate date) { return Optional.ofNullable(jpaQueryFactory .selectFrom(dailyMemberCertification) 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 index ed9ea08e..218bdcbd 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -44,6 +44,15 @@ public List findParticipantsByRoomId(Long roomId) { .fetch(); } + public List findAllParticipantsByRoomId(Long roomId) { + return jpaQueryFactory + .selectFrom(participant) + .where( + participant.room.id.eq(roomId) + ) + .fetch(); + } + public List findNotDeletedParticipantsByMemberId(Long memberId) { return jpaQueryFactory .selectFrom(participant) diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index 7f5d2df8..10d40ce0 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -33,6 +33,7 @@ public enum ErrorMessage { PARTICIPANT_DEPORT_ERROR("방장은 자신을 추방할 수 없습니다."), IMAGE_CONVERT_FAIL("이미지 변환을 실패했습니다."), UNAVAILABLE_TO_CHANGE_CERTIFY_TIME("이미 한명 이상이 인증을 하면 인증 시간을 바꿀 수 없습니다."), + CERTIFIED_ROOM_EXIT_FAILED("오늘 인증한 방은 나갈 수 없습니다."), LOGIN_FAILED("로그인에 실패했습니다."), LOGIN_FAILED_ADMIN_KEY("어드민키가 달라요"), From 4e69376352570e6663559e27c9657fb08a9399dc Mon Sep 17 00:00:00 2001 From: HyuckJuneHong Date: Sat, 2 Dec 2023 22:13:13 +0900 Subject: [PATCH 157/185] =?UTF-8?q?hotfix:=20=EC=BF=A0=ED=8F=B0=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/application/coupon/CouponMapper.java | 2 +- .../api/application/coupon/CouponService.java | 3 +++ .../notification/NotificationService.java | 4 ++-- .../moabam/api/dto/coupon/CouponResponse.java | 2 +- src/main/resources/static/docs/coupon.html | 22 +++++++++---------- .../resources/static/docs/notification.html | 4 ++-- .../moabam/support/snippet/CouponSnippet.java | 4 ++-- 7 files changed, 22 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java index eae0c895..a3cbe1c3 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponMapper.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponMapper.java @@ -33,7 +33,7 @@ public static Coupon toEntity(Long adminId, CreateCouponRequest coupon) { public static CouponResponse toResponse(Coupon coupon) { return CouponResponse.builder() .id(coupon.getId()) - .adminName("ID : " + coupon.getAdminId()) + .adminId(coupon.getAdminId()) .name(coupon.getName()) .description(coupon.getDescription()) .point(coupon.getPoint()) diff --git a/src/main/java/com/moabam/api/application/coupon/CouponService.java b/src/main/java/com/moabam/api/application/coupon/CouponService.java index 3a765dff..00ec0fa3 100644 --- a/src/main/java/com/moabam/api/application/coupon/CouponService.java +++ b/src/main/java/com/moabam/api/application/coupon/CouponService.java @@ -6,6 +6,7 @@ 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; @@ -34,7 +35,9 @@ 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; diff --git a/src/main/java/com/moabam/api/application/notification/NotificationService.java b/src/main/java/com/moabam/api/application/notification/NotificationService.java index 523ac965..3d8c859b 100644 --- a/src/main/java/com/moabam/api/application/notification/NotificationService.java +++ b/src/main/java/com/moabam/api/application/notification/NotificationService.java @@ -31,7 +31,7 @@ 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] - 인증 시간!"; + private static final String CERTIFY_TIME_BODY = "[%s] - 5분 후 인증 시간입니다!"; private final ClockHolder clockHolder; private final FcmService fcmService; @@ -62,7 +62,7 @@ public void sendCouponIssueResult(Long memberId, String couponName, String body) fcmService.sendAsync(fcmToken, COMMON_TITLE, notificationBody); } - @Scheduled(cron = "0 50 * * * *") + @Scheduled(cron = "0 55 * * * *") public void sendCertificationTime() { int certificationTime = (clockHolder.times().getHour() + ONE_HOUR) % HOURS_IN_A_DAY; List participants = participantSearchRepository.findAllByRoomCertifyTime(certificationTime); diff --git a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java index 69b37827..d709490e 100644 --- a/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java +++ b/src/main/java/com/moabam/api/dto/coupon/CouponResponse.java @@ -10,7 +10,7 @@ @Builder public record CouponResponse( Long id, - String adminName, + Long adminId, String name, String description, int point, diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index af8593f3..b0e56d22 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -479,7 +479,7 @@

응답

HTTP/1.1 201 Created
-Access-Control-Allow-Origin:
+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
@@ -506,7 +506,7 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
@@ -534,17 +534,17 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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: 201
+Content-Length: 192
 
 {
   "id" : 24,
-  "adminName" : "ID : 1",
+  "adminId" : 1,
   "name" : "couponName",
   "description" : "",
   "point" : 10,
@@ -584,17 +584,17 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
+Content-Length: 193
 
 [ {
   "id" : 25,
-  "adminName" : "ID : 1",
+  "adminId" : 1,
   "name" : "coupon1",
   "description" : "",
   "point" : 10,
@@ -631,7 +631,7 @@ 

응답

HTTP/1.1 409 Conflict
-Access-Control-Allow-Origin:
+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
@@ -666,7 +666,7 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
@@ -700,7 +700,7 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html
index 57c06cd4..a6327a68 100644
--- a/src/main/resources/static/docs/notification.html
+++ b/src/main/resources/static/docs/notification.html
@@ -469,7 +469,7 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
@@ -499,7 +499,7 @@ 

응답

HTTP/1.1 200 OK
-Access-Control-Allow-Origin:
+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
diff --git a/src/test/java/com/moabam/support/snippet/CouponSnippet.java b/src/test/java/com/moabam/support/snippet/CouponSnippet.java
index 71b36dc5..852c6f4c 100644
--- a/src/test/java/com/moabam/support/snippet/CouponSnippet.java
+++ b/src/test/java/com/moabam/support/snippet/CouponSnippet.java
@@ -21,7 +21,7 @@ public final class CouponSnippet {
 
 	public static final ResponseFieldsSnippet COUPON_RESPONSE = responseFields(
 		fieldWithPath("id").type(NUMBER).description("쿠폰 ID"),
-		fieldWithPath("adminName").type(STRING).description("쿠폰 관리자명"),
+		fieldWithPath("adminId").type(NUMBER).description("쿠폰 관리자 ID"),
 		fieldWithPath("name").type(STRING).description("쿠폰명"),
 		fieldWithPath("description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"),
 		fieldWithPath("point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"),
@@ -38,7 +38,7 @@ public final class CouponSnippet {
 
 	public static final ResponseFieldsSnippet COUPON_STATUS_RESPONSE = responseFields(
 		fieldWithPath("[].id").type(NUMBER).description("쿠폰 ID"),
-		fieldWithPath("[].adminName").type(STRING).description("쿠폰 관리자명"),
+		fieldWithPath("[].adminId").type(NUMBER).description("쿠폰 관리자 ID"),
 		fieldWithPath("[].name").type(STRING).description("쿠폰명"),
 		fieldWithPath("[].description").type(STRING).description("쿠폰에 대한 간단 소개 (NULL 가능)"),
 		fieldWithPath("[].point").type(NUMBER).description("쿠폰 사용 시, 제공하는 포인트량"),

From a500b86cb6a7b618c33c371947222b3eb47b9e25 Mon Sep 17 00:00:00 2001
From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com>
Date: Sat, 2 Dec 2023 22:30:08 +0900
Subject: [PATCH 158/185] =?UTF-8?q?fix:=20=EB=B0=A9=EC=9D=98=20=EC=9D=B8?=
 =?UTF-8?q?=EC=A6=9D=20=EC=8B=9C=EA=B0=84=EC=97=90=EB=8A=94=20=EC=9E=85?=
 =?UTF-8?q?=EC=9E=A5=ED=95=98=EC=A7=80=20=EB=AA=BB=ED=95=98=EB=8F=84?=
 =?UTF-8?q?=EB=A1=9D=20=EC=88=98=EC=A0=95=20(#223)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../moabam/api/application/room/RoomService.java   | 14 ++++++++++++++
 .../moabam/global/error/model/ErrorMessage.java    |  1 +
 2 files changed, 15 insertions(+)

diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java
index 43b5c281..48dd9d04 100644
--- a/src/main/java/com/moabam/api/application/room/RoomService.java
+++ b/src/main/java/com/moabam/api/application/room/RoomService.java
@@ -3,6 +3,7 @@
 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;
 
@@ -199,6 +200,7 @@ private void validateManagerAuthorization(Participant participant) {
 
 	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);
@@ -219,6 +221,18 @@ private void validateEnteredRoomCount(Long memberId, RoomType roomType) {
 		}
 	}
 
+	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);
diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java
index 10d40ce0..3684e3ec 100644
--- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java
+++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java
@@ -34,6 +34,7 @@ public enum ErrorMessage {
 	IMAGE_CONVERT_FAIL("이미지 변환을 실패했습니다."),
 	UNAVAILABLE_TO_CHANGE_CERTIFY_TIME("이미 한명 이상이 인증을 하면 인증 시간을 바꿀 수 없습니다."),
 	CERTIFIED_ROOM_EXIT_FAILED("오늘 인증한 방은 나갈 수 없습니다."),
+	ROOM_ENTER_FAILED("해당 방의 인증 시간에는 입장할 수 없습니다."),
 
 	LOGIN_FAILED("로그인에 실패했습니다."),
 	LOGIN_FAILED_ADMIN_KEY("어드민키가 달라요"),

From 96ccb1bebb281b5deeb1bb11d4dd0f2218bba360 Mon Sep 17 00:00:00 2001
From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com>
Date: Sun, 3 Dec 2023 00:06:55 +0900
Subject: [PATCH 159/185] =?UTF-8?q?fix:=20Room=20soft=20delete=EB=A1=9C=20?=
 =?UTF-8?q?=EB=B3=80=EA=B2=BD=20(#226)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* fix: Room soft delete로 변경

* docs: mysql 수정

* fix: checkstyle
---
 infra/mysql/initdb.d/init.sql                              | 1 +
 .../java/com/moabam/api/application/room/RoomService.java  | 6 ------
 src/main/java/com/moabam/api/domain/room/Participant.java  | 1 -
 src/main/java/com/moabam/api/domain/room/Room.java         | 7 +++++++
 .../com/moabam/api/presentation/RoomControllerTest.java    | 4 ----
 5 files changed, 8 insertions(+), 11 deletions(-)

diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql
index 9a64249e..aa8bdafa 100644
--- a/infra/mysql/initdb.d/init.sql
+++ b/infra/mysql/initdb.d/init.sql
@@ -212,6 +212,7 @@ create table room
     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)
diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java
index 48dd9d04..5ad81780 100644
--- a/src/main/java/com/moabam/api/application/room/RoomService.java
+++ b/src/main/java/com/moabam/api/application/room/RoomService.java
@@ -16,7 +16,6 @@
 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.Certification;
 import com.moabam.api.domain.room.Participant;
 import com.moabam.api.domain.room.Room;
 import com.moabam.api.domain.room.RoomType;
@@ -122,11 +121,6 @@ public void exitRoom(Long memberId, Long roomId) {
 			return;
 		}
 
-		List routines = routineRepository.findAllByRoomId(roomId);
-		List certifications = certificationService.findCertifications(routines);
-
-		certificationService.deleteCertifications(certifications);
-		routineRepository.deleteAll(routines);
 		roomRepository.delete(room);
 	}
 
diff --git a/src/main/java/com/moabam/api/domain/room/Participant.java b/src/main/java/com/moabam/api/domain/room/Participant.java
index d6274a4d..0a0d3d02 100644
--- a/src/main/java/com/moabam/api/domain/room/Participant.java
+++ b/src/main/java/com/moabam/api/domain/room/Participant.java
@@ -76,6 +76,5 @@ public void updateCertifyCount() {
 
 	public void removeRoom() {
 		this.deletedRoomTitle = this.room.getTitle();
-		this.room = null;
 	}
 }
diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java
index 00a7ac42..5505b3d7 100644
--- a/src/main/java/com/moabam/api/domain/room/Room.java
+++ b/src/main/java/com/moabam/api/domain/room/Room.java
@@ -4,7 +4,10 @@
 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;
@@ -26,6 +29,7 @@
 @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_5 = 5;
@@ -85,6 +89,9 @@ public class Room extends BaseTimeEntity {
 	@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;
diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java
index dbca7ec2..f4c4fc1d 100644
--- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java
+++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java
@@ -709,12 +709,8 @@ void manager_delete_room_success() throws Exception {
 			.andExpect(status().isOk())
 			.andDo(print());
 
-		List deletedRoom = roomRepository.findAll();
-		List deletedRoutine = routineRepository.findAll();
 		List deletedParticipant = participantRepository.findAll();
 
-		assertThat(deletedRoom).isEmpty();
-		assertThat(deletedRoutine).hasSize(0);
 		assertThat(deletedParticipant).hasSize(1);
 		assertThat(deletedParticipant.get(0).getDeletedAt()).isNotNull();
 		assertThat(deletedParticipant.get(0).getDeletedRoomTitle()).isNotNull();

From a78b82f7cb92321ac21d62d6c98ef2a07e252904 Mon Sep 17 00:00:00 2001
From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com>
Date: Sun, 3 Dec 2023 00:45:37 +0900
Subject: [PATCH 160/185] =?UTF-8?q?fix:=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?=
 =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=B4=20=EB=B3=B5=EC=82=AC=EB=90=98?=
 =?UTF-8?q?=EB=8A=94=20=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#228)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../moabam/api/application/room/CertificationService.java | 8 --------
 .../com/moabam/api/application/room/SearchService.java    | 1 +
 2 files changed, 1 insertion(+), 8 deletions(-)

diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java
index 6f024e6f..c34c7be5 100644
--- a/src/main/java/com/moabam/api/application/room/CertificationService.java
+++ b/src/main/java/com/moabam/api/application/room/CertificationService.java
@@ -113,14 +113,6 @@ public Certification findCertification(Long certificationId) {
 			.orElseThrow(() -> new NotFoundException(CERTIFICATION_NOT_FOUND));
 	}
 
-	public List findCertifications(List routines) {
-		return certificationsSearchRepository.findCertificationsByRoutines(routines);
-	}
-
-	public void deleteCertifications(List certifications) {
-		certificationRepository.deleteAll(certifications);
-	}
-
 	private void validateCertifyTime(LocalDateTime now, int certifyTime) {
 		LocalTime targetTime = LocalTime.of(certifyTime, 0);
 		LocalDateTime targetDateTime = LocalDateTime.of(now.toLocalDate(), targetTime);
diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java
index aafb5bb9..8938dc76 100644
--- a/src/main/java/com/moabam/api/application/room/SearchService.java
+++ b/src/main/java/com/moabam/api/application/room/SearchService.java
@@ -263,6 +263,7 @@ private List getTodayCertificateRankResponses(Long
 		List participants = participantSearchRepository.findAllParticipantsByRoomId(roomId);
 		List members = memberService.getRoomMembers(participants.stream()
 			.map(Participant::getMemberId)
+			.distinct()
 			.toList());
 
 		List knocks = notificationService.getMyKnockStatusInRoom(memberId, roomId, participants);

From 9e81987d79c64cca507b7d9d1d7666f9a0e7c494 Mon Sep 17 00:00:00 2001
From: ymkim97 
Date: Sun, 3 Dec 2023 00:52:01 +0900
Subject: [PATCH 161/185] =?UTF-8?q?hotfix:=20distinct=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/main/java/com/moabam/api/application/room/SearchService.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java
index 8938dc76..380fedb0 100644
--- a/src/main/java/com/moabam/api/application/room/SearchService.java
+++ b/src/main/java/com/moabam/api/application/room/SearchService.java
@@ -325,6 +325,7 @@ private List uncompletedMembers(
 
 		List allMemberIds = participants.stream()
 			.map(Participant::getMemberId)
+			.distinct()
 			.collect(Collectors.toList());
 
 		List certifiedMemberIds = dailyMemberCertifications.stream()

From 12b5f085953f69194ebeb2b3b4b675198d2067d2 Mon Sep 17 00:00:00 2001
From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com>
Date: Sun, 3 Dec 2023 01:17:10 +0900
Subject: [PATCH 162/185] =?UTF-8?q?fix:=20=EA=B8=B0=EC=97=AC=EB=8F=84=20?=
 =?UTF-8?q?=EB=B2=84=EA=B7=B8=20=ED=95=B4=EA=B2=B0=20(#230)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 src/main/java/com/moabam/api/application/room/SearchService.java | 1 +
 1 file changed, 1 insertion(+)

diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java
index 380fedb0..fc5dce63 100644
--- a/src/main/java/com/moabam/api/application/room/SearchService.java
+++ b/src/main/java/com/moabam/api/application/room/SearchService.java
@@ -374,6 +374,7 @@ private CertificationImagesResponse getCertificationImages(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));
 

From eaaad580086bb7fe54fcfd69dd07597632d09e08 Mon Sep 17 00:00:00 2001
From: Park Seyeon 
Date: Sun, 3 Dec 2023 14:37:59 +0900
Subject: [PATCH 163/185] fix: admin token (#231)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가

* test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가

* feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가

* feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러

* style: checkstyle 에러 fix

* refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo

* fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경

* refactor: 랭킹 응답 반환 객체 변수면 s 제거

Co-authored-by: Kim Heebin 

* refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경

* fix: ObjectMapper에러 수정

* fix: objectMapper 삭제 추가

* feat: 어드민 서비스 로그인 기능 추가

* refactor: 어드민 config 업데이트

* fix: test application.yml 수정

* test: stub에서의 타입 오류 해결

* style: 변수면 변경

* feat: 어드민과 일반 유저간 토큰 생성, 검증 분리 및 로그인 분리

* feat: 회원 인증시 뱃지 생성기능 추가

* refactor: config 수정

* refactor: 코딩 스타일 재적용

---------

Co-authored-by: Kim Heebin 
---
 .../admin/application/admin/AdminMapper.java  |  4 +
 .../admin/application/admin/AdminService.java | 19 +++--
 .../com/moabam/admin/domain/admin/Admin.java  |  2 +-
 .../presentation/admin/AdminController.java   |  8 +-
 .../auth/AuthorizationService.java            | 71 ++++++++++------
 .../auth/JwtAuthenticationService.java        | 16 +++-
 .../application/auth/JwtProviderService.java  | 24 ++++--
 .../api/application/member/BadgeService.java  | 32 +++++++
 .../api/application/member/MemberMapper.java  |  8 ++
 .../application/ranking/RankingMapper.java    | 10 +--
 .../application/ranking/RankingService.java   | 11 ++-
 .../room/CertificationService.java            |  3 +
 .../auth/repository/TokenRepository.java      | 17 ++--
 .../moabam/api/domain/member/BadgeType.java   | 20 +++--
 .../member/repository/BadgeRepository.java    |  3 +
 .../auth/filter/AuthorizationFilter.java      | 32 +++++--
 .../moabam/global/auth/filter/CorsFilter.java |  5 +-
 .../global/common/util/CookieUtils.java       |  6 +-
 .../global/config/AllowOriginConfig.java      |  5 +-
 .../com/moabam/global/config/TokenConfig.java |  6 +-
 .../global/error/model/ErrorMessage.java      |  1 +
 src/main/resources/config                     |  2 +-
 src/main/resources/static/docs/coupon.html    | 12 +--
 src/main/resources/static/docs/index.html     |  2 +-
 .../resources/static/docs/notification.html   |  2 +-
 .../auth/AuthorizationServiceTest.java        | 48 +++++++----
 .../auth/JwtAuthenticationServiceTest.java    | 16 ++--
 .../auth/JwtProviderServiceTest.java          | 12 +--
 .../room/CertificationServiceTest.java        |  4 +
 .../domain/member/BadgeRepositoryTest.java    | 84 +++++++++++++++++++
 .../domain/member/MemberRepositoryTest.java   |  9 +-
 .../redis/TokenRepostiroyTest.java            |  7 +-
 .../presentation/MemberControllerTest.java    | 51 +++++------
 .../presentation/RankingControllerTest.java   |  2 +-
 .../api/presentation/RoomControllerTest.java  |  8 ++
 .../global/common/util/CookieMakeTest.java    |  8 +-
 .../filter/AuthorizationFilterTest.java       | 11 +--
 .../support/common/WithFilterSupporter.java   |  3 +-
 .../common/WithoutFilterSupporter.java        |  2 +-
 .../support/fixture/JwtProviderFixture.java   |  4 +-
 .../fixture/MemberInfoSearchFixture.java      |  8 +-
 src/test/resources/application.yml            |  3 +
 42 files changed, 416 insertions(+), 185 deletions(-)
 create mode 100644 src/main/java/com/moabam/api/application/member/BadgeService.java
 create mode 100644 src/test/java/com/moabam/api/domain/member/BadgeRepositoryTest.java

diff --git a/src/main/java/com/moabam/admin/application/admin/AdminMapper.java b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java
index d9cf0b35..648c2e4d 100644
--- a/src/main/java/com/moabam/admin/application/admin/AdminMapper.java
+++ b/src/main/java/com/moabam/admin/application/admin/AdminMapper.java
@@ -2,6 +2,10 @@
 
 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) {
diff --git a/src/main/java/com/moabam/admin/application/admin/AdminService.java b/src/main/java/com/moabam/admin/application/admin/AdminService.java
index 892f15cf..01243bd7 100644
--- a/src/main/java/com/moabam/admin/application/admin/AdminService.java
+++ b/src/main/java/com/moabam/admin/application/admin/AdminService.java
@@ -4,27 +4,27 @@
 
 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.AuthorizationService;
 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 jakarta.servlet.http.HttpServletResponse;
 import lombok.RequiredArgsConstructor;
 
 @Service
 @RequiredArgsConstructor
+@Transactional(readOnly = true)
 public class AdminService {
 
 	@Value("${admin}")
 	private String adminLoginKey;
 
-	private final AuthorizationService authorizationService;
 	private final AdminRepository adminRepository;
 
 	public void validate(String state) {
@@ -33,12 +33,9 @@ public void validate(String state) {
 		}
 	}
 
-	public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse,
-		AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
-		LoginResponse loginResponse = login(authorizationTokenInfoResponse);
-		authorizationService.issueServiceToken(httpServletResponse, loginResponse.publicClaim());
-
-		return loginResponse;
+	@Transactional
+	public LoginResponse signUpOrLogin(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
+		return login(authorizationTokenInfoResponse);
 	}
 
 	private LoginResponse login(AuthorizationTokenInfoResponse authorizationTokenInfoResponse) {
@@ -53,4 +50,8 @@ private Admin signUp(Long 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
index d63cbeff..eeff1bc3 100644
--- a/src/main/java/com/moabam/admin/domain/admin/Admin.java
+++ b/src/main/java/com/moabam/admin/domain/admin/Admin.java
@@ -37,7 +37,7 @@ public class Admin extends BaseTimeEntity {
 
 	@Enumerated(EnumType.STRING)
 	@Column(name = "role", nullable = false)
-	@ColumnDefault("'USER'")
+	@ColumnDefault("'ADMIN'")
 	private Role role;
 
 	@Builder
diff --git a/src/main/java/com/moabam/admin/presentation/admin/AdminController.java b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java
index 8ef4354b..cea5bb99 100644
--- a/src/main/java/com/moabam/admin/presentation/admin/AdminController.java
+++ b/src/main/java/com/moabam/admin/presentation/admin/AdminController.java
@@ -31,9 +31,11 @@ public LoginResponse authorizationTokenIssue(@RequestBody AuthorizationCodeRespo
 		HttpServletResponse httpServletResponse) {
 		adminService.validate(authorizationCodeResponse.state());
 		AuthorizationTokenResponse tokenResponse = authorizationService.requestAdminToken(authorizationCodeResponse);
-		AuthorizationTokenInfoResponse authorizationTokenInfoResponse = authorizationService.requestTokenInfo(
-			tokenResponse);
+		AuthorizationTokenInfoResponse authorizationTokenInfoResponse =
+			authorizationService.requestTokenInfo(tokenResponse);
+		LoginResponse loginResponse = adminService.signUpOrLogin(authorizationTokenInfoResponse);
+		authorizationService.issueServiceToken(httpServletResponse, loginResponse.publicClaim());
 
-		return adminService.signUpOrLogin(httpServletResponse, authorizationTokenInfoResponse);
+		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
index a2db2e9f..3966d2c4 100644
--- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
+++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java
@@ -9,11 +9,13 @@
 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;
@@ -24,8 +26,9 @@
 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.common.util.cookie.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;
@@ -47,9 +50,10 @@ public class AuthorizationService {
 	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 CookieUtils cookieUtils;
+	private final AllowOriginConfig allowOriginsConfig;
 
 	public void redirectToLoginPage(HttpServletResponse httpServletResponse) {
 		String authorizationCodeUri = getAuthorizationCodeUri();
@@ -87,24 +91,25 @@ public LoginResponse signUpOrLogin(HttpServletResponse httpServletResponse,
 
 	public void issueServiceToken(HttpServletResponse response, PublicClaim publicClaim) {
 		String accessToken = jwtProviderService.provideAccessToken(publicClaim);
-		String refreshToken = jwtProviderService.provideRefreshToken();
+		String refreshToken = jwtProviderService.provideRefreshToken(publicClaim.role());
 		TokenSaveValue tokenSaveRequest = AuthMapper.toTokenSaveValue(refreshToken, null);
 
-		tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest);
+		tokenRepository.saveToken(publicClaim.id(), tokenSaveRequest, publicClaim.role());
 
+		String domain = getDomain(publicClaim.role());
+
+		response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain));
 		response.addCookie(
-			cookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire()));
-		response.addCookie(
-			cookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire()));
+			CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain));
 		response.addCookie(
-			cookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire()));
+			CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire(), domain));
 	}
 
-	public void validTokenPair(Long id, String oldRefreshToken) {
-		TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id);
+	public void validTokenPair(Long id, String oldRefreshToken, Role role) {
+		TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(id, role);
 
 		if (!tokenSaveValue.refreshToken().equals(oldRefreshToken)) {
-			tokenRepository.delete(id);
+			tokenRepository.delete(id, role);
 
 			throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL);
 		}
@@ -113,7 +118,7 @@ public void validTokenPair(Long id, String oldRefreshToken) {
 	public void logout(AuthMember authMember, HttpServletRequest httpServletRequest,
 		HttpServletResponse httpServletResponse) {
 		removeToken(httpServletRequest, httpServletResponse);
-		tokenRepository.delete(authMember.id());
+		tokenRepository.delete(authMember.id(), authMember.role());
 		fcmService.deleteTokenByMemberId(authMember.id());
 	}
 
@@ -122,12 +127,11 @@ public void removeToken(HttpServletRequest httpServletRequest, HttpServletRespon
 			return;
 		}
 
-		Arrays.stream(httpServletRequest.getCookies())
-			.forEach(cookie -> {
-				if (cookie.getName().contains("token")) {
-					httpServletResponse.addCookie(cookieUtils.deleteCookie(cookie));
-				}
-			});
+		Arrays.stream(httpServletRequest.getCookies()).forEach(cookie -> {
+			if (cookie.getName().contains("token")) {
+				httpServletResponse.addCookie(CookieUtils.deleteCookie(cookie));
+			}
+		});
 	}
 
 	@Transactional
@@ -137,12 +141,18 @@ public void unLinkMember(AuthMember authMember) {
 		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));
+			oauth2AuthorizationServerRequestService.unlinkMemberRequest(oAuthConfig.provider().unlink(),
+				oAuthConfig.client().adminKey(), unlinkRequestParam(socialId));
 			log.info("회원 탈퇴 성공 : [socialId={}]", socialId);
 		} catch (BadRequestException badRequestException) {
 			log.warn("회원 탈퇴요청 실패 : 카카오 연결 오류");
@@ -174,8 +184,7 @@ private String generateQueryParamsWith(AuthorizationCodeRequest authorizationCod
 			.queryParam("client_id", authorizationCodeRequest.clientId())
 			.queryParam("redirect_uri", authorizationCodeRequest.redirectUri());
 
-		if (authorizationCodeRequest.scope() != null
-			&& !authorizationCodeRequest.scope().isEmpty()) {
+		if (authorizationCodeRequest.scope() != null && !authorizationCodeRequest.scope().isEmpty()) {
 			String scopes = String.join(",", authorizationCodeRequest.scope());
 			authorizationCodeUri.queryParam("scope", scopes);
 		}
@@ -194,8 +203,8 @@ private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code,
 			oAuthConfig, code, redirectUri);
 		MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest);
 		ResponseEntity authorizationTokenResponse =
-			oauth2AuthorizationServerRequestService.requestAuthorizationServer(oAuthConfig.provider().tokenUri(),
-				uriParams);
+			oauth2AuthorizationServerRequestService
+				.requestAuthorizationServer(oAuthConfig.provider().tokenUri(), uriParams);
 
 		return authorizationTokenResponse.getBody();
 	}
@@ -213,4 +222,14 @@ private MultiValueMap generateTokenRequest(AuthorizationTokenReq
 
 		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
index eaf1d456..52c652f9 100644
--- a/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
+++ b/src/main/java/com/moabam/api/application/auth/JwtAuthenticationService.java
@@ -1,11 +1,13 @@
 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;
@@ -22,10 +24,12 @@ public class JwtAuthenticationService {
 
 	private final TokenConfig tokenConfig;
 
-	public boolean isTokenExpire(String token) {
+	public boolean isTokenExpire(String token, Role role) {
 		try {
+			Key key = getSecret(role);
+
 			Jwts.parserBuilder()
-				.setSigningKey(tokenConfig.getKey())
+				.setSigningKey(key)
 				.build()
 				.parseClaimsJws(token);
 			return false;
@@ -36,6 +40,14 @@ public boolean isTokenExpire(String token) {
 		}
 	}
 
+	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);
diff --git a/src/main/java/com/moabam/api/application/auth/JwtProviderService.java b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
index 4ea924dd..816985f2 100644
--- a/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
+++ b/src/main/java/com/moabam/api/application/auth/JwtProviderService.java
@@ -1,9 +1,11 @@
 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;
 
@@ -22,23 +24,23 @@ public String provideAccessToken(PublicClaim publicClaim) {
 		return generateIdToken(publicClaim, tokenConfig.getAccessExpire());
 	}
 
-	public String provideRefreshToken() {
-		return generateCommonInfo(tokenConfig.getRefreshExpire());
+	public String provideRefreshToken(Role role) {
+		return generateCommonInfo(tokenConfig.getRefreshExpire(), role);
 	}
 
 	private String generateIdToken(PublicClaim publicClaim, long expireTime) {
-		return commonInfo(expireTime)
+		return commonInfo(expireTime, publicClaim.role())
 			.claim("id", publicClaim.id())
 			.claim("nickname", publicClaim.nickname())
 			.claim("role", publicClaim.role())
 			.compact();
 	}
 
-	private String generateCommonInfo(long expireTime) {
-		return commonInfo(expireTime).compact();
+	private String generateCommonInfo(long expireTime, Role role) {
+		return commonInfo(expireTime, role).compact();
 	}
 
-	private JwtBuilder commonInfo(long expireTime) {
+	private JwtBuilder commonInfo(long expireTime, Role role) {
 		Date issueDate = new Date();
 		Date expireDate = new Date(issueDate.getTime() + expireTime);
 
@@ -48,6 +50,14 @@ private JwtBuilder commonInfo(long expireTime) {
 			.setIssuer(tokenConfig.getIss())
 			.setIssuedAt(issueDate)
 			.setExpiration(expireDate)
-			.signWith(tokenConfig.getKey(), SignatureAlgorithm.HS256);
+			.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/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
index 67fd8a92..7d80c808 100644
--- a/src/main/java/com/moabam/api/application/member/MemberMapper.java
+++ b/src/main/java/com/moabam/api/application/member/MemberMapper.java
@@ -13,6 +13,7 @@
 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;
@@ -96,6 +97,13 @@ public static RankingInfo toRankingInfo(Member member) {
 			.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);
 	}
diff --git a/src/main/java/com/moabam/api/application/ranking/RankingMapper.java b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java
index 06ad3389..817ccdc8 100644
--- a/src/main/java/com/moabam/api/application/ranking/RankingMapper.java
+++ b/src/main/java/com/moabam/api/application/ranking/RankingMapper.java
@@ -25,7 +25,7 @@ public static TopRankingInfo topRankingResponse(int rank, long score, RankingInf
 
 	public static TopRankingInfo topRankingResponse(int rank, UpdateRanking updateRanking) {
 		return TopRankingInfo.builder()
-			.rank(rank)
+			.rank(rank + 1)
 			.score(updateRanking.score())
 			.nickname(updateRanking.rankingInfo().nickname())
 			.image(updateRanking.rankingInfo().image())
@@ -33,11 +33,7 @@ public static TopRankingInfo topRankingResponse(int rank, UpdateRanking updateRa
 			.build();
 	}
 
-	public static TopRankingResponse topRankingResponses(TopRankingInfo myRanking,
-		List topRankings) {
-		return TopRankingResponse.builder()
-			.topRankings(topRankings)
-			.myRanking(myRanking)
-			.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
index aa48370d..65e620a3 100644
--- a/src/main/java/com/moabam/api/application/ranking/RankingService.java
+++ b/src/main/java/com/moabam/api/application/ranking/RankingService.java
@@ -35,8 +35,8 @@ public void addRanking(RankingInfo rankingInfo, Long totalCertifyCount) {
 	}
 
 	public void updateScores(List updateRankings) {
-		updateRankings.forEach(updateRanking ->
-			zSetRedisRepository.add(RANKING, updateRanking.rankingInfo(), updateRanking.score()));
+		updateRankings.forEach(
+			updateRanking -> zSetRedisRepository.add(RANKING, updateRanking.rankingInfo(), updateRanking.score()));
 	}
 
 	public void changeInfos(RankingInfo before, RankingInfo after) {
@@ -50,15 +50,14 @@ public void removeRanking(RankingInfo rankingInfo) {
 	public TopRankingResponse getMemberRanking(UpdateRanking myRankingInfo) {
 		List topRankings = getTopRankings();
 		Long myRanking = zSetRedisRepository.reverseRank(RANKING, myRankingInfo.rankingInfo());
-		TopRankingInfo myRankingInfoResponse =
-			RankingMapper.topRankingResponse(myRanking.intValue(), myRankingInfo);
+		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> topRankings = zSetRedisRepository.rangeJson(RANKING, START_INDEX,
+			LIMIT_INDEX);
 
 		Set scoreSet = new HashSet<>();
 		List topRankingInfo = new ArrayList<>();
diff --git a/src/main/java/com/moabam/api/application/room/CertificationService.java b/src/main/java/com/moabam/api/application/room/CertificationService.java
index c34c7be5..c4e86434 100644
--- a/src/main/java/com/moabam/api/application/room/CertificationService.java
+++ b/src/main/java/com/moabam/api/application/room/CertificationService.java
@@ -14,6 +14,7 @@
 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;
@@ -53,6 +54,7 @@ public class CertificationService {
 	private final DailyRoomCertificationRepository dailyRoomCertificationRepository;
 	private final DailyMemberCertificationRepository dailyMemberCertificationRepository;
 	private final MemberService memberService;
+	private final BadgeService badgeService;
 	private final BugService bugService;
 	private final ClockHolder clockHolder;
 
@@ -139,6 +141,7 @@ private void certifyMember(Long memberId, Long roomId, Participant participant,
 			roomId, participant);
 		dailyMemberCertificationRepository.save(dailyMemberCertification);
 		member.increaseTotalCertifyCount();
+		badgeService.createBadge(member.getId(), member.getTotalCertifyCount());
 		participant.updateCertifyCount();
 
 		saveNewCertifications(memberId, urls);
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
index 3286d0e4..e104dad0 100644
--- a/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java
+++ b/src/main/java/com/moabam/api/domain/auth/repository/TokenRepository.java
@@ -5,6 +5,7 @@
 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;
 
@@ -20,23 +21,23 @@ public TokenRepository(HashRedisRepository hashRedisRepository) {
 		this.hashRedisRepository = hashRedisRepository;
 	}
 
-	public void saveToken(Long memberId, TokenSaveValue tokenSaveRequest) {
-		String tokenKey = parseTokenKey(memberId);
+	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) {
-		String tokenKey = parseTokenKey(memberId);
+	public TokenSaveValue getTokenSaveValue(Long memberId, Role role) {
+		String tokenKey = parseTokenKey(memberId, role);
 		return (TokenSaveValue)hashRedisRepository.get(tokenKey);
 	}
 
-	public void delete(Long memberId) {
-		String tokenKey = parseTokenKey(memberId);
+	public void delete(Long memberId, Role role) {
+		String tokenKey = parseTokenKey(memberId, role);
 		hashRedisRepository.delete(tokenKey);
 	}
 
-	private String parseTokenKey(Long memberId) {
-		return "auth_" + memberId;
+	private String parseTokenKey(Long memberId, Role role) {
+		return role.name() + "_" + memberId;
 	}
 }
diff --git a/src/main/java/com/moabam/api/domain/member/BadgeType.java b/src/main/java/com/moabam/api/domain/member/BadgeType.java
index 82a7ebd1..e0819f9a 100644
--- a/src/main/java/com/moabam/api/domain/member/BadgeType.java
+++ b/src/main/java/com/moabam/api/domain/member/BadgeType.java
@@ -2,6 +2,7 @@
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.Optional;
 import java.util.Set;
 
 import com.moabam.api.dto.member.BadgeResponse;
@@ -11,16 +12,15 @@
 @Getter
 public enum BadgeType {
 
-	MORNING_BIRTH("MORNING", "오목눈이 탄생"),
-	MORNING_ADULT("MORNING", "어른 오목눈이"),
-	NIGHT_BIRTH("NIGHT", "부엉이 탄생"),
-	NIGHT_ADULT("NIGHT", "어른 부엉이");
+	BIRTH(10, "탄생 축하 뱃지"),
+	LEVEL10(100, "10레벨 뱃지"),
+	LEVEL50(500, "50레벨 뱃지");
 
-	private final String period;
+	private final long certifyCount;
 	private final String korean;
 
-	BadgeType(String period, String korean) {
-		this.period = period;
+	BadgeType(long certifyCount, String korean) {
+		this.certifyCount = certifyCount;
 		this.korean = korean;
 	}
 
@@ -32,4 +32,10 @@ public static List memberBadgeMap(Set badgeTypes) {
 				.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/repository/BadgeRepository.java b/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java
index dd16ebff..ac313e25 100644
--- a/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java
+++ b/src/main/java/com/moabam/api/domain/member/repository/BadgeRepository.java
@@ -3,7 +3,10 @@
 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/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
index 325afe37..ecc5438e 100644
--- a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
+++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java
@@ -13,8 +13,10 @@
 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;
 
@@ -39,8 +41,9 @@ public class AuthorizationFilter extends OncePerRequestFilter {
 
 	@Override
 	protected void doFilterInternal(@NotNull HttpServletRequest httpServletRequest,
-		@NotNull HttpServletResponse httpServletResponse,
-		@NotNull FilterChain filterChain) throws ServletException, IOException {
+		@NotNull HttpServletResponse httpServletResponse, @NotNull FilterChain filterChain) throws
+		ServletException,
+		IOException {
 
 		if (isPermit(httpServletRequest)) {
 			filterChain.doFilter(httpServletRequest, httpServletResponse);
@@ -73,32 +76,45 @@ private void invoke(HttpServletRequest httpServletRequest, HttpServletResponse h
 			throw new UnauthorizedException(ErrorMessage.GRANT_FAILED);
 		}
 
-		handleTokenAuthenticate(cookies, httpServletResponse);
+		handleTokenAuthenticate(cookies, httpServletResponse, httpServletRequest);
 	}
 
 	private boolean isTokenTypeBearer(Cookie[] cookies) {
 		return "Bearer".equals(extractTokenFromCookie(cookies, "token_type"));
 	}
 
-	private void handleTokenAuthenticate(Cookie[] cookies,
-		HttpServletResponse httpServletResponse) {
+	private void handleTokenAuthenticate(Cookie[] cookies, HttpServletResponse httpServletResponse,
+		HttpServletRequest httpServletRequest) {
 		String accessToken = extractTokenFromCookie(cookies, "access_token");
 		PublicClaim publicClaim = authenticationService.parseClaim(accessToken);
 
-		if (authenticationService.isTokenExpire(accessToken)) {
+		if (authenticationService.isTokenExpire(accessToken, publicClaim.role())) {
 			String refreshToken = extractTokenFromCookie(cookies, "refresh_token");
 
-			if (authenticationService.isTokenExpire(refreshToken)) {
+			if (authenticationService.isTokenExpire(refreshToken, publicClaim.role())) {
 				throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL);
 			}
 
-			authorizationService.validTokenPair(publicClaim.id(), refreshToken);
+			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.GRANT_FAILED));
diff --git a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java
index f363c2df..70508dbd 100644
--- a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java
+++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java
@@ -66,10 +66,7 @@ protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServl
 	}
 
 	public String secureMatch(String refer) {
-		return allowOriginsConfig.origin().stream()
-			.filter(refer::contains)
-			.findFirst()
-			.orElse(null);
+		return allowOriginsConfig.origin().stream().filter(refer::contains).findFirst().orElse(null);
 	}
 
 	public boolean isOption(String method) {
diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java
index 892268da..5f59441d 100644
--- a/src/main/java/com/moabam/global/common/util/CookieUtils.java
+++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java
@@ -7,10 +7,11 @@
 @NoArgsConstructor(access = AccessLevel.PRIVATE)
 public class CookieUtils {
 
-	public static Cookie tokenCookie(String name, String value, long expireTime) {
+	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.setDomain(domain);
 		cookie.setPath("/");
 		cookie.setMaxAge((int)expireTime);
 		cookie.setAttribute("SameSite", "Lax");
@@ -18,10 +19,11 @@ public static Cookie tokenCookie(String name, String value, long expireTime) {
 		return cookie;
 	}
 
-	public static Cookie typeCookie(String value, long expireTime) {
+	public static Cookie typeCookie(String value, long expireTime, String domain) {
 		Cookie cookie = new Cookie("token_type", value);
 		cookie.setSecure(true);
 		cookie.setHttpOnly(true);
+		cookie.setDomain(domain);
 		cookie.setPath("/");
 		cookie.setMaxAge((int)expireTime);
 		cookie.setAttribute("SameSite", "Lax");
diff --git a/src/main/java/com/moabam/global/config/AllowOriginConfig.java b/src/main/java/com/moabam/global/config/AllowOriginConfig.java
index d2ae8db6..b580a99f 100644
--- a/src/main/java/com/moabam/global/config/AllowOriginConfig.java
+++ b/src/main/java/com/moabam/global/config/AllowOriginConfig.java
@@ -6,7 +6,8 @@
 
 @ConfigurationProperties(prefix = "allows")
 public record AllowOriginConfig(
-	List origin
-) {
+	String adminDomain,
+	String domain,
+	List origin) {
 
 }
diff --git a/src/main/java/com/moabam/global/config/TokenConfig.java b/src/main/java/com/moabam/global/config/TokenConfig.java
index fe6bdc91..4bbe5a1e 100644
--- a/src/main/java/com/moabam/global/config/TokenConfig.java
+++ b/src/main/java/com/moabam/global/config/TokenConfig.java
@@ -16,13 +16,17 @@ public class TokenConfig {
 	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) {
+	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/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java
index 3684e3ec..ee5584c7 100644
--- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java
+++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java
@@ -12,6 +12,7 @@ public enum ErrorMessage {
 	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("신고 요청하고자 하는 방이나 대상이 존재하지 않습니다."),
 
diff --git a/src/main/resources/config b/src/main/resources/config
index 77b52691..3aa15e1b 160000
--- a/src/main/resources/config
+++ b/src/main/resources/config
@@ -1 +1 @@
-Subproject commit 77b52691bc52d2c0506cc039ee8ec21d1292380d
+Subproject commit 3aa15e1b92cc4573ccb5f18f120fb98ab66b48fa
diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html
index b0e56d22..a52ba6e9 100644
--- a/src/main/resources/static/docs/coupon.html
+++ b/src/main/resources/static/docs/coupon.html
@@ -461,7 +461,7 @@ 

요청

POST /admins/coupons HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 178
+Content-Length: 186
 Host: localhost:8080
 
 {
@@ -540,7 +540,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 192 +Content-Length: 202 { "id" : 24, @@ -571,7 +571,7 @@

요청

POST /coupons/search HTTP/1.1
 Content-Type: application/json;charset=UTF-8
-Content-Length: 41
+Content-Length: 44
 Host: localhost:8080
 
 {
@@ -590,7 +590,7 @@ 

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 193 +Content-Length: 203 [ { "id" : 25, @@ -637,7 +637,7 @@

응답

Access-Control-Allow-Credentials: true Access-Control-Max-Age: 3600 Content-Type: application/json -Content-Length: 63 +Content-Length: 65 { "message" : "이미 쿠폰 발급에 성공했습니다!" @@ -716,7 +716,7 @@

응답

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 154f140e..4c8f2a12 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index a6327a68..d4ddae0c 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index 93f6c5a3..b746b6d6 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -23,10 +23,12 @@ 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; @@ -36,8 +38,8 @@ 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.cookie.CookieDevUtils; -import com.moabam.global.common.util.cookie.CookieUtils; +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; @@ -64,6 +66,9 @@ class AuthorizationServiceTest { @Mock MemberService memberService; + @Mock + AdminService adminService; + @Mock JwtProviderService jwtProviderService; @@ -73,19 +78,22 @@ class AuthorizationServiceTest { @Mock TokenRepository tokenRepository; - CookieUtils cookieUtils; + AllowOriginConfig allowOriginsConfig; OAuthConfig oauthConfig; TokenConfig tokenConfig; AuthorizationService noPropertyService; OAuthConfig noOAuthConfig; + String domain = "Test"; @BeforeEach public void initParams() { - cookieUtils = new CookieDevUtils(); - tokenConfig = new TokenConfig(null, 100000, 150000, - "testtesttesttesttesttesttesttesttesttesttesttesttesttesttesttest"); + 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); - ReflectionTestUtils.setField(authorizationService, "cookieUtils", cookieUtils); oauthConfig = new OAuthConfig( new OAuthConfig.Provider("https://authorization/url", "http://redirect/url", "http://token/url", @@ -98,7 +106,8 @@ public void initParams() { 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, jwtProviderService, tokenRepository, cookieUtils); + oAuth2AuthorizationServerRequestService, memberService, adminService, + jwtProviderService, tokenRepository, allowOriginsConfig); } @DisplayName("인가코드 URI 생성 매퍼 실패") @@ -222,7 +231,7 @@ void signUp_success(boolean isSignUp) { AuthorizationTokenInfoResponse authorizationTokenInfoResponse = AuthorizationResponseFixture.authorizationTokenInfoResponse(); LoginResponse loginResponse = LoginResponse.builder() - .publicClaim(PublicClaim.builder().id(1L).nickname("nickname").build()) + .publicClaim(PublicClaim.builder().id(1L).nickname("nickname").role(Role.USER).build()) .isSignUp(isSignUp) .build(); @@ -255,22 +264,25 @@ void signUp_success(boolean isSignUp) { @Test void valid_token_in_redis() { // Given - willReturn(TokenSaveValueFixture.tokenSaveValue("token")).given(tokenRepository).getTokenSaveValue(1L); + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L, Role.USER); // When + Then - assertThatNoException().isThrownBy(() -> authorizationService.validTokenPair(1L, "token")); + 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); + willReturn(TokenSaveValueFixture.tokenSaveValue("token")) + .given(tokenRepository).getTokenSaveValue(1L, Role.USER); // When + Then - assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "oldToken")).isInstanceOf( + assertThatThrownBy(() -> authorizationService.validTokenPair(1L, "oldToken", Role.USER)).isInstanceOf( UnauthorizedException.class).hasMessage(ErrorMessage.AUTHENTICATE_FAIL.getMessage()); - verify(tokenRepository).delete(1L); + verify(tokenRepository).delete(1L, Role.USER); } @DisplayName("토큰 삭제 성공") @@ -278,8 +290,10 @@ void valid_token_failby_notEquals_token() { void error_with_expire_token(@WithMember AuthMember authMember) { // given MockHttpServletRequest httpServletRequest = new MockHttpServletRequest(); - httpServletRequest.setCookies(cookieUtils.tokenCookie("access_token", "value", 100000), - cookieUtils.tokenCookie("refresh_token", "value", 100000), cookieUtils.typeCookie("Bearer", 100000)); + 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(); @@ -292,7 +306,7 @@ void error_with_expire_token(@WithMember AuthMember authMember) { assertThat(cookie.getMaxAge()).isZero(); assertThat(cookie.getValue()).isEqualTo("value"); - verify(tokenRepository).delete(authMember.id()); + verify(tokenRepository).delete(authMember.id(), Role.USER); } @DisplayName("토큰 없어서 삭제 실패") diff --git a/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java b/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java index 9c56f174..37149fb2 100644 --- a/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/JwtAuthenticationServiceTest.java @@ -16,8 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.junit.jupiter.MockitoExtension; -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.model.PublicClaim; import com.moabam.global.config.TokenConfig; import com.moabam.global.error.exception.UnauthorizedException; @@ -32,6 +31,7 @@ class JwtAuthenticationServiceTest { String originIss = "PARK"; String originSecretKey = "testestestestestestestestestesttestestestestestestestestestest"; + String adminKey = "testestestestestestestestestesttestestestestestestestestestest"; long originId = 1L; long originAccessExpire = 100000; long originRefreshExpire = 150000; @@ -42,7 +42,7 @@ class JwtAuthenticationServiceTest { @BeforeEach void initConfig() { - tokenConfig = new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey); + tokenConfig = new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey, adminKey); jwtProviderService = new JwtProviderService(tokenConfig); jwtAuthenticationService = new JwtAuthenticationService(tokenConfig); } @@ -55,7 +55,7 @@ void token_authentication_success() { // when, then assertThatNoException().isThrownBy(() -> - jwtAuthenticationService.isTokenExpire(token)); + jwtAuthenticationService.isTokenExpire(token, Role.USER)); } @DisplayName("토큰 인증 시간 만료 테스트") @@ -63,14 +63,14 @@ void token_authentication_success() { void token_authentication_time_expire() { // Given PublicClaim publicClaim = PublicClaimFixture.publicClaim(); - TokenConfig tokenConfig = new TokenConfig(originIss, 0, 0, originSecretKey); + 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); + boolean result = jwtAuthenticationService.isTokenExpire(token, Role.USER); // Then assertThat(result).isTrue(); @@ -98,7 +98,7 @@ void token_authenticate_failBy_payload() { parts[2]); // Then - Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(newToken)) + Assertions.assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(newToken, Role.USER)) .isInstanceOf(UnauthorizedException.class); } @@ -121,7 +121,7 @@ void token_authenticate_failBy_key() { .compact(); // When + Then - assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(token)) + assertThatThrownBy(() -> jwtAuthenticationService.isTokenExpire(token, Role.USER)) .isExactlyInstanceOf(UnauthorizedException.class); } diff --git a/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java index 9c985f57..4a85f46a 100644 --- a/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/JwtProviderServiceTest.java @@ -12,6 +12,7 @@ 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; @@ -24,6 +25,7 @@ class JwtProviderServiceTest { String iss = "PARK"; String secretKey = "testestestestestestestestestesttestestestestestestestestestest"; + String adminKey = "testestestestestestestestestesttestestestestestestestestestest"; long id = 1L; @DisplayName("access 토큰 생성 성공") @@ -32,7 +34,7 @@ void create_access_token_success() throws JSONException { // given long accessExpire = 10000L; - TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey, adminKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); PublicClaim publicClaim = PublicClaimFixture.publicClaim(); @@ -97,11 +99,11 @@ void create_refresh_token_success() throws JSONException { // given long refreshExpire = 15000L; - TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey, adminKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); // when - String refreshToken = jwtProviderService.provideRefreshToken(); + String refreshToken = jwtProviderService.provideRefreshToken(Role.USER); String[] parts = refreshToken.split("\\."); String headers = new String(Base64.getDecoder().decode(parts[0])); @@ -128,7 +130,7 @@ void create_access_token_fail() { // given long accessExpire = -1L; - TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey); + TokenConfig tokenConfig = new TokenConfig("PARK", accessExpire, 0L, secretKey, adminKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); PublicClaim publicClaim = PublicClaimFixture.publicClaim(); @@ -149,7 +151,7 @@ void create_token_fail() { // given long refreshExpire = -1L; - TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey); + TokenConfig tokenConfig = new TokenConfig("PARK", 0L, refreshExpire, secretKey, adminKey); JwtProviderService jwtProviderService = new JwtProviderService(tokenConfig); PublicClaim publicClaim = PublicClaimFixture.publicClaim(); diff --git a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java index f35b1483..e02eac44 100644 --- a/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/CertificationServiceTest.java @@ -21,6 +21,7 @@ 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; @@ -82,6 +83,9 @@ class CertificationServiceTest { @Mock private ImageService imageService; + @Mock + private BadgeService badgeService; + @Mock private ClockHolder clockHolder; 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 index 90729351..ee18e08c 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -137,11 +137,10 @@ void search_info_success() { member.enterRoom(RoomType.MORNING); memberRepository.save(member); - Badge morningBirth = BadgeFixture.badge(member.getId(), BadgeType.MORNING_BIRTH); - Badge morningAdult = BadgeFixture.badge(member.getId(), BadgeType.MORNING_ADULT); - Badge nightBirth = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_BIRTH); - Badge nightAdult = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_ADULT); - List badges = List.of(morningBirth, morningAdult, nightBirth, nightAdult); + 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 diff --git a/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java b/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java index d720226f..006d392e 100644 --- a/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java +++ b/src/test/java/com/moabam/api/infrastructure/redis/TokenRepostiroyTest.java @@ -15,6 +15,7 @@ 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; @@ -35,7 +36,7 @@ void save_token_suceess() { // When + Then Assertions.assertThatNoException() - .isThrownBy(() -> tokenRepository.saveToken(1L, TokenSaveValueFixture.tokenSaveValue())); + .isThrownBy(() -> tokenRepository.saveToken(1L, TokenSaveValueFixture.tokenSaveValue(), Role.USER)); } @DisplayName("토큰 조회 성공") @@ -46,7 +47,7 @@ void token_get_success() { .given(hashRedisRepository).get(anyString()); // when - TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L); + TokenSaveValue tokenSaveValue = tokenRepository.getTokenSaveValue(123L, Role.USER); // then assertAll( @@ -60,6 +61,6 @@ void token_get_success() { void delete_token_suceess() { // When + Then Assertions.assertThatNoException() - .isThrownBy(() -> tokenRepository.delete(1L)); + .isThrownBy(() -> tokenRepository.delete(1L, Role.USER)); } } diff --git a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java index 6297567c..80efe377 100644 --- a/src/test/java/com/moabam/api/presentation/MemberControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberControllerTest.java @@ -58,6 +58,7 @@ 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; @@ -167,14 +168,14 @@ void setUp() { void logout_success() throws Exception { // given TokenSaveValue tokenSaveValue = TokenSaveValueFixture.tokenSaveValue(); - tokenRepository.saveToken(member.getId(), 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())) + Assertions.assertThatThrownBy(() -> tokenRepository.getTokenSaveValue(member.getId(), Role.USER)) .isInstanceOf(UnauthorizedException.class); } @@ -264,10 +265,10 @@ void unlink_social_member_failby_meber_is_manger() throws Exception { @Test void search_my_info_success() throws Exception { // given - Badge morningBirth = BadgeFixture.badge(member.getId(), BadgeType.MORNING_BIRTH); - Badge morningAdult = BadgeFixture.badge(member.getId(), BadgeType.MORNING_ADULT); - Badge nightBirth = BadgeFixture.badge(member.getId(), BadgeType.NIGHT_BIRTH); - List badges = List.of(morningBirth, morningAdult, nightBirth); + 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(); @@ -301,14 +302,12 @@ void search_my_info_success() throws Exception { // 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].badge").value("탄생 축하 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), - MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(false), MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), MockMvcResultMatchers.jsonPath("$.nightBug").value(member.getBug().getNightBug()) @@ -352,14 +351,12 @@ void search_my_info_with_no_badge_success() throws Exception { // 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].badge").value("탄생 축하 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), - MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(false), MockMvcResultMatchers.jsonPath("$.goldenBug").value(member.getBug().getGoldenBug()), MockMvcResultMatchers.jsonPath("$.morningBug").value(member.getBug().getMorningBug()), MockMvcResultMatchers.jsonPath("$.nightBug").value(member.getBug().getNightBug()) @@ -374,11 +371,9 @@ void search_friend_info_success() throws Exception { Member friend = MemberFixture.member("123456789"); memberRepository.save(friend); - Badge morningBirth = BadgeFixture.badge(friend.getId(), BadgeType.MORNING_BIRTH); - Badge morningAdult = BadgeFixture.badge(friend.getId(), BadgeType.MORNING_ADULT); - Badge nightBirth = BadgeFixture.badge(friend.getId(), BadgeType.NIGHT_BIRTH); - Badge nightAdult = BadgeFixture.badge(friend.getId(), BadgeType.NIGHT_ADULT); - List badges = List.of(morningBirth, morningAdult, nightBirth, nightAdult); + 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(); @@ -387,10 +382,10 @@ void search_friend_info_success() throws Exception { itemRepository.saveAll(List.of(night, morning, killer)); Inventory nightInven = InventoryFixture.inventory(friend.getId(), night); - nightInven.select(member); + nightInven.select(friend); Inventory morningInven = InventoryFixture.inventory(friend.getId(), morning); - morningInven.select(member); + morningInven.select(friend); Inventory killerInven = InventoryFixture.inventory(friend.getId(), killer); friend.changeDefaultSkintUrl(morning); @@ -415,14 +410,12 @@ void search_friend_info_success() throws Exception { 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].badge").value("탄생 축하 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[0].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("어른 오목눈이"), + MockMvcResultMatchers.jsonPath("$.badges[1].badge").value("10레벨 뱃지"), MockMvcResultMatchers.jsonPath("$.badges[1].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("부엉이 탄생"), - MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(true), - MockMvcResultMatchers.jsonPath("$.badges[3].badge").value("어른 부엉이"), - MockMvcResultMatchers.jsonPath("$.badges[3].unlock").value(true) + MockMvcResultMatchers.jsonPath("$.badges[2].badge").value("50레벨 뱃지"), + MockMvcResultMatchers.jsonPath("$.badges[2].unlock").value(false) ).andDo(print()); } diff --git a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java index f9b866d4..6399e91c 100644 --- a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java @@ -79,7 +79,7 @@ void top_ranking() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.topRankings", hasSize(10))) .andExpect(jsonPath("$.myRanking.nickname", is(members.get(0).getNickname()))) - .andExpect(jsonPath("$.myRanking.rank", is(21))); + .andExpect(jsonPath("$.myRanking.rank", is(22))); // then diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index f4c4fc1d..36acce1b 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -8,6 +8,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -18,9 +19,11 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; +import org.mockito.BDDMockito; 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.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -48,6 +51,7 @@ 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.SystemClockHolder; import com.moabam.support.annotation.WithMember; import com.moabam.support.common.WithoutFilterSupporter; import com.moabam.support.fixture.BugFixture; @@ -98,6 +102,9 @@ class RoomControllerTest extends WithoutFilterSupporter { @Autowired private InventoryRepository inventoryRepository; + @SpyBean + private SystemClockHolder clockHolder; + Member member; @BeforeAll @@ -424,6 +431,7 @@ void enter_room_with_password_success() throws Exception { @Test void enter_room_with_no_password_success() throws Exception { // given + BDDMockito.given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 12, 3, 14, 30, 0)); Room room = RoomFixture.room(); roomRepository.save(room); diff --git a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java index 63cd7a79..5af52083 100644 --- a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java +++ b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java @@ -13,11 +13,13 @@ @ExtendWith(MockitoExtension.class) class CookieMakeTest { + String domain = "test"; + @DisplayName("prod환경에서 cookie 생성 테스트") @Test void create_test() { // Given - Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000); + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000, domain); // When + Then assertAll( @@ -33,7 +35,7 @@ void create_test() { @Test void delete_test() { // given - Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000); + Cookie cookie = CookieUtils.tokenCookie("access_token", "value", 10000, domain); // when Cookie deletedCookie = CookieUtils.deleteCookie(cookie); @@ -49,7 +51,7 @@ void delete_test() { @Test void typeCookie_create_test() { // Given + When - Cookie cookie = CookieUtils.typeCookie("Bearer", 10000); + Cookie cookie = CookieUtils.typeCookie("Bearer", 10000, domain); // then assertThat(cookie.getName()).isEqualTo("token_type"); diff --git a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java index 31f6303a..24e0c5c4 100644 --- a/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java +++ b/src/test/java/com/moabam/global/filter/AuthorizationFilterTest.java @@ -21,6 +21,7 @@ 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; @@ -98,7 +99,7 @@ void filter_have_any_access_token_error() throws ServletException, IOException { httpServletRequest.addHeader("token_type", "Bearer"); // when - String token = jwtProviderService.provideRefreshToken(); + String token = jwtProviderService.provideRefreshToken(Role.USER); httpServletRequest.setCookies(new Cookie("refresh_token", token)); authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); @@ -128,7 +129,7 @@ void filter_have_any_refresh_token_error() throws ServletException, IOException new Cookie("access_token", token)); when(jwtAuthenticationService.parseClaim(token)).thenReturn(publicClaim); - when(jwtAuthenticationService.isTokenExpire(token)).thenReturn(true); + when(jwtAuthenticationService.isTokenExpire(token, Role.USER)).thenReturn(true); authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); @@ -152,15 +153,15 @@ void issue_new_token_success() throws ServletException, IOException { // when String accessToken = jwtProviderService.provideAccessToken(publicClaim); - String refreshToken = jwtProviderService.provideRefreshToken(); + 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)).thenReturn(true); - when(jwtAuthenticationService.isTokenExpire(refreshToken)).thenReturn(false); + when(jwtAuthenticationService.isTokenExpire(accessToken, Role.USER)).thenReturn(true); + when(jwtAuthenticationService.isTokenExpire(refreshToken, Role.USER)).thenReturn(false); authorizationFilter.doFilter(httpServletRequest, httpServletResponse, mockFilterChain); diff --git a/src/test/java/com/moabam/support/common/WithFilterSupporter.java b/src/test/java/com/moabam/support/common/WithFilterSupporter.java index 69b5f114..cd92c8cf 100644 --- a/src/test/java/com/moabam/support/common/WithFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithFilterSupporter.java @@ -13,6 +13,7 @@ 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; @@ -47,7 +48,7 @@ void setUpMockMvc(RestDocumentationContextProvider contextProvider) { jwtProviderService.provideAccessToken(PublicClaimFixture.publicClaim()), tokenConfig.getRefreshExpire())) .cookie(cookieUtils.tokenCookie("refresh_token", - jwtProviderService.provideRefreshToken(), + 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 index 18d726cc..a58671cb 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -30,7 +30,7 @@ public class WithoutFilterSupporter { @SpyBean private CorsFilter corsFilter; - @SpyBean + @MockBean private AllowOriginConfig allowOriginConfig; @BeforeEach diff --git a/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java index 7a3c530a..50fa714c 100644 --- a/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java +++ b/src/test/java/com/moabam/support/fixture/JwtProviderFixture.java @@ -7,12 +7,14 @@ 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); + TokenConfig tokenConfig = + new TokenConfig(originIss, originAccessExpire, originRefreshExpire, originSecretKey, adminKey); return new JwtProviderService(tokenConfig); } diff --git a/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java index e4bfa647..6f43c132 100644 --- a/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java +++ b/src/test/java/com/moabam/support/fixture/MemberInfoSearchFixture.java @@ -22,9 +22,9 @@ public static List friendMemberInfo() { public static List friendMemberInfo(long total) { return List.of( - new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.MORNING_BIRTH, + 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.NIGHT_BIRTH, + new MemberInfo(NICKNAME, PROFILE_IMAGE, MORNING_EGG, NIGHT_EGG, INTRO, total, BadgeType.LEVEL10, 0, 0, 0) ); } @@ -32,9 +32,9 @@ public static List friendMemberInfo(long total) { public static List myInfo(String morningImage, String nightImage) { return List.of( new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, - BadgeType.MORNING_BIRTH, 0, 0, 0), + BadgeType.BIRTH, 0, 0, 0), new MemberInfo(NICKNAME, PROFILE_IMAGE, morningImage, nightImage, INTRO, TOTAL_CERTIFY_COUNT, - BadgeType.NIGHT_BIRTH, 0, 0, 0) + BadgeType.LEVEL10, 0, 0, 0) ); } } diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c485b58d..7083d3d7 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -70,8 +70,11 @@ token: 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" From dab0c583cd5e156d90146a5cfab63f89d5cc01d3 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Sun, 3 Dec 2023 14:44:25 +0900 Subject: [PATCH 164/185] =?UTF-8?q?=08fix:=20=EC=9D=B8=EC=A6=9D=EC=9C=A8?= =?UTF-8?q?=20=ED=95=98=EB=9D=BD=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=20(#233)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 코드 정리 * fix: 인증율 하락 수정 --- .../api/application/room/SearchService.java | 41 +++++++++++++------ .../CertificationsSearchRepository.java | 9 ---- .../ParticipantSearchRepository.java | 20 +++++++-- .../room/RoomServiceConcurrencyTest.java | 2 +- .../application/room/SearchServiceTest.java | 4 +- 5 files changed, 47 insertions(+), 29 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index fc5dce63..20f12aae 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -5,6 +5,8 @@ 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; @@ -85,7 +87,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId, LocalDate roomId, dailyMemberCertifications, date, room.getRoomType()); List certifiedDates = getCertifiedDatesBeforeWeek(roomId); double completePercentage = calculateCompletePercentage(dailyMemberCertifications.size(), - room.getCurrentUserCount()); + room, date); return RoomMapper.toRoomDetailsResponse(memberId, room, managerNickname, routineResponses, certifiedDates, todayCertificateRankResponses, completePercentage); @@ -94,7 +96,7 @@ public RoomDetailsResponse getRoomDetails(Long memberId, Long roomId, LocalDate public MyRoomsResponse getMyRooms(Long memberId) { LocalDate today = clockHolder.date(); List myRoomResponses = new ArrayList<>(); - List participants = participantSearchRepository.findNotDeletedParticipantsByMemberId(memberId); + List participants = participantSearchRepository.findNotDeletedAllByMemberId(memberId); for (Participant participant : participants) { Room room = participant.getRoom(); @@ -108,7 +110,7 @@ public MyRoomsResponse getMyRooms(Long memberId) { } public RoomsHistoryResponse getJoinHistory(Long memberId) { - List participants = participantSearchRepository.findAllParticipantsByMemberId(memberId); + List participants = participantSearchRepository.findAllByMemberId(memberId); List roomHistoryResponses = participants.stream() .map(participant -> { if (participant.getRoom() == null) { @@ -134,7 +136,7 @@ public ManageRoomResponse getRoomForModification(Long memberId, Long roomId) { Room room = participant.getRoom(); List routineResponses = getRoutineResponses(roomId); - List participants = participantSearchRepository.findParticipantsByRoomId(roomId); + List participants = participantSearchRepository.findAllByRoomId(roomId); List memberIds = participants.stream() .map(Participant::getMemberId) .toList(); @@ -158,7 +160,6 @@ public GetAllRoomsResponse getAllRooms(@Nullable RoomType roomType, @Nullable Lo return RoomMapper.toSearchAllRoomsResponse(hasNext, getAllRoomResponse); } - // TODO: full-text search 로 바꾸면서 리팩토링 예정 public GetAllRoomsResponse searchRooms(String keyword, @Nullable RoomType roomType, @Nullable Long roomId) { List getAllRoomResponse = new ArrayList<>(); List rooms = new ArrayList<>(); @@ -258,9 +259,8 @@ private List getRoutineResponses(Long roomId) { private List getTodayCertificateRankResponses(Long memberId, Long roomId, List dailyMemberCertifications, LocalDate date, RoomType roomType) { - List responses = new ArrayList<>(); List certifications = certificationsSearchRepository.findCertifications(roomId, date); - List participants = participantSearchRepository.findAllParticipantsByRoomId(roomId); + List participants = participantSearchRepository.findAllWithDeletedByRoomId(roomId); List members = memberService.getRoomMembers(participants.stream() .map(Participant::getMemberId) .distinct() @@ -273,10 +273,14 @@ private List getTodayCertificateRankResponses(Long .toList(); List inventories = inventorySearchRepository.findDefaultInventories(memberIds, roomType.name()); - responses.addAll(completedMembers(dailyMemberCertifications, members, certifications, participants, date, - knocks, inventories)); - responses.addAll(uncompletedMembers(dailyMemberCertifications, members, participants, date, knocks, - inventories)); + List responses = new ArrayList<>( + completedMembers(dailyMemberCertifications, members, certifications, participants, date, knocks, + inventories)); + + if (clockHolder.date() == date) { + responses.addAll(uncompletedMembers(dailyMemberCertifications, members, participants, date, knocks, + inventories)); + } return responses; } @@ -392,8 +396,19 @@ private List getCertifiedDatesBeforeWeek(Long roomId) { .toList(); } - private double calculateCompletePercentage(int certifiedMembersCount, int currentsMembersCount) { - double completePercentage = ((double)certifiedMembersCount / currentsMembersCount) * 100; + private double calculateCompletePercentage(int certifiedMembersCount, Room room, LocalDate date) { + if (date != 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/domain/room/repository/CertificationsSearchRepository.java b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java index 1226d03f..8563245c 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/CertificationsSearchRepository.java @@ -15,7 +15,6 @@ 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.Routine; import com.querydsl.jpa.impl.JPAQueryFactory; import jakarta.persistence.LockModeType; @@ -36,14 +35,6 @@ public List findCertifications(Long roomId, LocalDate date) { .fetch(); } - public List findCertificationsByRoutines(List routines) { - return jpaQueryFactory.selectFrom(certification) - .where( - certification.routine.in(routines) - ) - .fetch(); - } - public Optional findDailyMemberCertification(Long memberId, Long roomId, LocalDate date) { return Optional.ofNullable(jpaQueryFactory .selectFrom(dailyMemberCertification) 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 index 218bdcbd..03a7ee66 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -3,6 +3,7 @@ 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; @@ -34,7 +35,7 @@ public Optional findOne(Long memberId, Long roomId) { ); } - public List findParticipantsByRoomId(Long roomId) { + public List findAllByRoomId(Long roomId) { return jpaQueryFactory .selectFrom(participant) .where( @@ -44,7 +45,7 @@ public List findParticipantsByRoomId(Long roomId) { .fetch(); } - public List findAllParticipantsByRoomId(Long roomId) { + public List findAllWithDeletedByRoomId(Long roomId) { return jpaQueryFactory .selectFrom(participant) .where( @@ -53,7 +54,18 @@ public List findAllParticipantsByRoomId(Long roomId) { .fetch(); } - public List findNotDeletedParticipantsByMemberId(Long memberId) { + 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() @@ -64,7 +76,7 @@ public List findNotDeletedParticipantsByMemberId(Long memberId) { .fetch(); } - public List findAllParticipantsByMemberId(Long memberId) { + public List findAllByMemberId(Long memberId) { return jpaQueryFactory .selectFrom(participant) .leftJoin(participant.room, room).fetchJoin() diff --git a/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java index 7e1c0f41..91e011da 100644 --- a/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java +++ b/src/test/java/com/moabam/api/application/room/RoomServiceConcurrencyTest.java @@ -95,7 +95,7 @@ void enter_room_concurrency_test() throws InterruptedException { countDownLatch.await(); - List actual = participantSearchRepository.findParticipantsByRoomId(room.getId()); + 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(); diff --git a/src/test/java/com/moabam/api/application/room/SearchServiceTest.java b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java index 70e26d2f..6562caa2 100644 --- a/src/test/java/com/moabam/api/application/room/SearchServiceTest.java +++ b/src/test/java/com/moabam/api/application/room/SearchServiceTest.java @@ -82,7 +82,7 @@ void get_my_rooms_success() { Participant participant3 = RoomFixture.participant(room3, memberId); List participants = List.of(participant1, participant2, participant3); - given(participantSearchRepository.findNotDeletedParticipantsByMemberId(memberId)).willReturn(participants); + 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); @@ -130,7 +130,7 @@ void get_my_join_history_success() { when(participant3.getDeletedAt()).thenReturn(today); when(participant3.getDeletedRoomTitle()).thenReturn("밤 - 첫 번째 방"); - given(participantSearchRepository.findAllParticipantsByMemberId(memberId)).willReturn(participants); + given(participantSearchRepository.findAllByMemberId(memberId)).willReturn(participants); // when RoomsHistoryResponse response = searchService.getJoinHistory(memberId); From afab1214bcd706a017d16fa4bf529de07b619cef Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Sun, 3 Dec 2023 15:07:22 +0900 Subject: [PATCH 165/185] fix: admin token fix (#234) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 * fix: ObjectMapper에러 수정 * fix: objectMapper 삭제 추가 * feat: 어드민 서비스 로그인 기능 추가 * refactor: 어드민 config 업데이트 * fix: test application.yml 수정 * test: stub에서의 타입 오류 해결 * style: 변수면 변경 * feat: 어드민과 일반 유저간 토큰 생성, 검증 분리 및 로그인 분리 * feat: 회원 인증시 뱃지 생성기능 추가 * refactor: config 수정 * refactor: 코딩 스타일 재적용 * fix: 도메인 변경 --------- Co-authored-by: Kim Heebin --- .../com/moabam/api/application/auth/AuthorizationService.java | 2 ++ src/main/java/com/moabam/global/common/util/CookieUtils.java | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 3966d2c4..5bcd7b4f 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -99,6 +99,8 @@ public void issueServiceToken(HttpServletResponse response, PublicClaim publicCl String domain = getDomain(publicClaim.role()); response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain)); + response.addCookie( + CookieUtils.typeCookie("Test_be_erase", tokenConfig.getRefreshExpire(), publicClaim.role().name())); response.addCookie( CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain)); response.addCookie( diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index 5f59441d..14c6866a 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -11,7 +11,7 @@ public static Cookie tokenCookie(String name, String value, long expireTime, Str Cookie cookie = new Cookie(name, value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain(domain); + cookie.setDomain("moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); @@ -23,7 +23,7 @@ public static Cookie typeCookie(String value, long expireTime, String domain) { Cookie cookie = new Cookie("token_type", value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain(domain); + cookie.setDomain("moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); From 63c7b2d3bd2a0e0533797ff401fdaf1787a730fc Mon Sep 17 00:00:00 2001 From: parksey Date: Sun, 3 Dec 2023 15:54:31 +0900 Subject: [PATCH 166/185] =?UTF-8?q?hotfix:=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=A4=EC=A0=95=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/moabam/global/common/util/CookieUtils.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index 14c6866a..f45ae1fe 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -11,7 +11,7 @@ public static Cookie tokenCookie(String name, String value, long expireTime, Str Cookie cookie = new Cookie(name, value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain("moabam.com"); + cookie.setDomain(".moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); @@ -23,7 +23,7 @@ public static Cookie typeCookie(String value, long expireTime, String domain) { Cookie cookie = new Cookie("token_type", value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain("moabam.com"); + cookie.setDomain(".moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); From a90afcb52273591527f8015cc7056f8637bc0182 Mon Sep 17 00:00:00 2001 From: parksey Date: Sun, 3 Dec 2023 15:58:53 +0900 Subject: [PATCH 167/185] =?UTF-8?q?hotfix:=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EA=B4=80=EB=A0=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20rollback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/moabam/global/common/util/CookieUtils.java | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index f45ae1fe..eef1d32c 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -11,7 +11,6 @@ public static Cookie tokenCookie(String name, String value, long expireTime, Str Cookie cookie = new Cookie(name, value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain(".moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); @@ -23,7 +22,6 @@ public static Cookie typeCookie(String value, long expireTime, String domain) { Cookie cookie = new Cookie("token_type", value); cookie.setSecure(true); cookie.setHttpOnly(true); - cookie.setDomain(".moabam.com"); cookie.setPath("/"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "Lax"); From 6232842616e1350a621f3e27f09b89250cf0e6c2 Mon Sep 17 00:00:00 2001 From: Kim Heebin Date: Sun, 3 Dec 2023 18:25:18 +0900 Subject: [PATCH 168/185] =?UTF-8?q?feat:=20=EC=97=90=EB=9F=AC=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=8A=AC=EB=9E=99=20=EC=97=B0=EB=8F=99=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: logback slack appender 라이브러리 의존성 추가 * feat: 로그 파일 작성 --- build.gradle | 7 ++-- .../payment/TossPaymentService.java | 7 +++- src/main/resources/logback-spring.xml | 35 +++++++++++++++++++ 3 files changed, 46 insertions(+), 3 deletions(-) create mode 100644 src/main/resources/logback-spring.xml diff --git a/build.gradle b/build.gradle index 507516ff..c7f8c37e 100644 --- a/build.gradle +++ b/build.gradle @@ -102,11 +102,14 @@ dependencies { implementation platform("io.awspring.cloud:spring-cloud-aws-dependencies:3.0.2") implementation 'io.awspring.cloud:spring-cloud-aws-starter-s3' - // webflux + // Webflux implementation 'org.springframework.boot:spring-boot-starter-webflux' // Slack Webhook - implementation "net.gpedro.integrations.slack:slack-webhook:1.4.0" + 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' diff --git a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java index 476cfe24..35db1492 100644 --- a/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java +++ b/src/main/java/com/moabam/api/infrastructure/payment/TossPaymentService.java @@ -17,9 +17,11 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import reactor.core.publisher.Mono; @Service +@Slf4j @RequiredArgsConstructor public class TossPaymentService { @@ -43,7 +45,10 @@ public ConfirmTossPaymentResponse confirm(ConfirmPaymentRequest request) { .body(BodyInserters.fromValue(request)) .retrieve() .onStatus(HttpStatusCode::isError, response -> response.bodyToMono(ErrorResponse.class) - .flatMap(error -> Mono.error(new TossPaymentException(error.message())))) + .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/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 + + + + + + + + + + From 42a2fa83770629257579570494edb51b49a480df Mon Sep 17 00:00:00 2001 From: Park Seyeon Date: Sun, 3 Dec 2023 18:27:27 +0900 Subject: [PATCH 169/185] fix: admin token fix (#235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 회원의 랭킹 redis에 추가 및 삭제, 업데이트 기능 추가 * test: 회원 정보 변경 및 삭제 추가에 따른 랭킹 참여, 제외 테스트 코드 추가 * feat: 랭킹시스템 API 추가 및 랭킹 조회 기능 추가 * feat: 랭킹 조회 테스트 코드 추가 및 랭킹 업데이트 로직 각 업데이트 -> 스케쥴러 * style: checkstyle 에러 fix * refactor: 응답 객체명 변경 TopRankingInfoResponse -> TopRankingInfo * fix: 랭킹 업데이트 시간 15분 매초마다 동작하는 방식 -> 15분에 한 번만 실행되도록 변경 * refactor: 랭킹 응답 반환 객체 변수면 s 제거 Co-authored-by: Kim Heebin * refactor: ToprankingResponses 응답 객체 반환명 TopRankingResponse로 변경 * fix: ObjectMapper에러 수정 * fix: objectMapper 삭제 추가 * feat: 어드민 서비스 로그인 기능 추가 * refactor: 어드민 config 업데이트 * fix: test application.yml 수정 * test: stub에서의 타입 오류 해결 * style: 변수면 변경 * feat: 어드민과 일반 유저간 토큰 생성, 검증 분리 및 로그인 분리 * feat: 회원 인증시 뱃지 생성기능 추가 * refactor: config 수정 * refactor: 코딩 스타일 재적용 * fix: 도메인 변경 * hotfix: 서버 도메인 변경 * feat: 로그인 쿠키 도메인 관련 SameSite를 None으로 변경 --------- Co-authored-by: Kim Heebin --- .../auth/AuthorizationService.java | 35 ++++++++++--------- .../auth/filter/AuthorizationFilter.java | 8 ++--- .../global/common/util/CookieUtils.java | 6 ++-- .../global/error/model/ErrorMessage.java | 4 +++ .../global/common/util/CookieMakeTest.java | 2 +- 5 files changed, 32 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 5bcd7b4f..38cbe63d 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -76,7 +76,8 @@ public AuthorizationTokenResponse requestToken(AuthorizationCodeResponse authori public AuthorizationTokenInfoResponse requestTokenInfo(AuthorizationTokenResponse authorizationTokenResponse) { String tokenValue = generateTokenValue(authorizationTokenResponse.accessToken()); ResponseEntity authorizationTokenInfoResponse = - oauth2AuthorizationServerRequestService.tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); + oauth2AuthorizationServerRequestService + .tokenInfoRequest(oAuthConfig.provider().tokenInfo(), tokenValue); return authorizationTokenInfoResponse.getBody(); } @@ -99,12 +100,12 @@ public void issueServiceToken(HttpServletResponse response, PublicClaim publicCl String domain = getDomain(publicClaim.role()); response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain)); - response.addCookie( - CookieUtils.typeCookie("Test_be_erase", tokenConfig.getRefreshExpire(), publicClaim.role().name())); - response.addCookie( - CookieUtils.tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain)); - response.addCookie( - CookieUtils.tokenCookie("refresh_token", refreshToken, tokenConfig.getRefreshExpire(), domain)); + response.addCookie(CookieUtils + .tokenCookie("Test", publicClaim.role().name(), 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) { @@ -117,8 +118,8 @@ public void validTokenPair(Long id, String oldRefreshToken, Role role) { } } - public void logout(AuthMember authMember, HttpServletRequest httpServletRequest, - HttpServletResponse httpServletResponse) { + public void logout(AuthMember authMember, + HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) { removeToken(httpServletRequest, httpServletResponse); tokenRepository.delete(authMember.id(), authMember.role()); fcmService.deleteTokenByMemberId(authMember.id()); @@ -180,11 +181,13 @@ private String generateTokenValue(String 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()); + 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()); @@ -201,8 +204,8 @@ private void validAuthorizationGrant(String code) { } private AuthorizationTokenResponse issueTokenToAuthorizationServer(String code, String redirectUri) { - AuthorizationTokenRequest authorizationTokenRequest = AuthorizationMapper.toAuthorizationTokenRequest( - oAuthConfig, code, redirectUri); + AuthorizationTokenRequest authorizationTokenRequest = + AuthorizationMapper.toAuthorizationTokenRequest(oAuthConfig, code, redirectUri); MultiValueMap uriParams = generateTokenRequest(authorizationTokenRequest); ResponseEntity authorizationTokenResponse = oauth2AuthorizationServerRequestService diff --git a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java index ecc5438e..d9f1c8d0 100644 --- a/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/AuthorizationFilter.java @@ -73,7 +73,7 @@ private void invoke(HttpServletRequest httpServletRequest, HttpServletResponse h Cookie[] cookies = getCookiesOrThrow(httpServletRequest); if (!isTokenTypeBearer(cookies)) { - throw new UnauthorizedException(ErrorMessage.GRANT_FAILED); + throw new UnauthorizedException(ErrorMessage.TOKEN_TYPE_FAILED); } handleTokenAuthenticate(cookies, httpServletResponse, httpServletRequest); @@ -92,7 +92,7 @@ private void handleTokenAuthenticate(Cookie[] cookies, HttpServletResponse httpS String refreshToken = extractTokenFromCookie(cookies, "refresh_token"); if (authenticationService.isTokenExpire(refreshToken, publicClaim.role())) { - throw new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL); + throw new UnauthorizedException(ErrorMessage.TOKEN_EXPIRE); } validInvalidMember(publicClaim, refreshToken, httpServletRequest); @@ -117,7 +117,7 @@ private void validInvalidMember(PublicClaim publicClaim, String refreshToken, private Cookie[] getCookiesOrThrow(HttpServletRequest httpServletRequest) { return Optional.ofNullable(httpServletRequest.getCookies()) - .orElseThrow(() -> new UnauthorizedException(ErrorMessage.GRANT_FAILED)); + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.COOKIE_NOT_FOUND)); } private String extractTokenFromCookie(Cookie[] cookies, String tokenName) { @@ -125,6 +125,6 @@ private String extractTokenFromCookie(Cookie[] cookies, String tokenName) { .filter(cookie -> tokenName.equals(cookie.getName())) .map(Cookie::getValue) .findFirst() - .orElseThrow(() -> new UnauthorizedException(ErrorMessage.AUTHENTICATE_FAIL)); + .orElseThrow(() -> new UnauthorizedException(ErrorMessage.TOKEN_NOT_FOUND)); } } diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index eef1d32c..8a41f0bb 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -12,8 +12,9 @@ public static Cookie tokenCookie(String name, String value, long expireTime, Str cookie.setSecure(true); cookie.setHttpOnly(true); cookie.setPath("/"); + cookie.setDomain(domain); cookie.setMaxAge((int)expireTime); - cookie.setAttribute("SameSite", "Lax"); + cookie.setAttribute("SameSite", "None"); return cookie; } @@ -23,8 +24,9 @@ public static Cookie typeCookie(String value, long expireTime, String domain) { cookie.setSecure(true); cookie.setHttpOnly(true); cookie.setPath("/"); + cookie.setDomain(domain); cookie.setMaxAge((int)expireTime); - cookie.setAttribute("SameSite", "Lax"); + cookie.setAttribute("SameSite", "None"); return cookie; } diff --git a/src/main/java/com/moabam/global/error/model/ErrorMessage.java b/src/main/java/com/moabam/global/error/model/ErrorMessage.java index ee5584c7..62428059 100644 --- a/src/main/java/com/moabam/global/error/model/ErrorMessage.java +++ b/src/main/java/com/moabam/global/error/model/ErrorMessage.java @@ -40,8 +40,12 @@ public enum ErrorMessage { 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("참여할 수 있는 방의 개수가 모두 찼습니다."), diff --git a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java index 5af52083..2f7e2d09 100644 --- a/src/test/java/com/moabam/global/common/util/CookieMakeTest.java +++ b/src/test/java/com/moabam/global/common/util/CookieMakeTest.java @@ -27,7 +27,7 @@ void create_test() { () -> assertThat(cookie.getSecure()).isTrue(), () -> assertThat(cookie.getPath()).isEqualTo("/"), () -> assertThat(cookie.getMaxAge()).isEqualTo(10000), - () -> assertThat(cookie.getAttribute("SameSite")).isEqualTo("Lax") + () -> assertThat(cookie.getAttribute("SameSite")).isEqualTo("None") ); } From e89c22d180757ae8cedf015cea2f45d0cab73df9 Mon Sep 17 00:00:00 2001 From: parksey Date: Sun, 3 Dec 2023 18:34:57 +0900 Subject: [PATCH 170/185] =?UTF-8?q?hotfix:=20=EC=84=9C=EB=B8=8C=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/moabam/global/common/util/CookieUtils.java | 4 ++-- src/main/resources/static/docs/coupon.html | 2 +- src/main/resources/static/docs/index.html | 2 +- src/main/resources/static/docs/notification.html | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/moabam/global/common/util/CookieUtils.java b/src/main/java/com/moabam/global/common/util/CookieUtils.java index 8a41f0bb..53aaa881 100644 --- a/src/main/java/com/moabam/global/common/util/CookieUtils.java +++ b/src/main/java/com/moabam/global/common/util/CookieUtils.java @@ -12,7 +12,7 @@ public static Cookie tokenCookie(String name, String value, long expireTime, Str cookie.setSecure(true); cookie.setHttpOnly(true); cookie.setPath("/"); - cookie.setDomain(domain); + cookie.setDomain("moabam.com"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "None"); @@ -24,7 +24,7 @@ public static Cookie typeCookie(String value, long expireTime, String domain) { cookie.setSecure(true); cookie.setHttpOnly(true); cookie.setPath("/"); - cookie.setDomain(domain); + cookie.setDomain("moabam.com"); cookie.setMaxAge((int)expireTime); cookie.setAttribute("SameSite", "None"); diff --git a/src/main/resources/static/docs/coupon.html b/src/main/resources/static/docs/coupon.html index a52ba6e9..8041f735 100644 --- a/src/main/resources/static/docs/coupon.html +++ b/src/main/resources/static/docs/coupon.html @@ -716,7 +716,7 @@

응답

diff --git a/src/main/resources/static/docs/index.html b/src/main/resources/static/docs/index.html index 4c8f2a12..215db37a 100644 --- a/src/main/resources/static/docs/index.html +++ b/src/main/resources/static/docs/index.html @@ -616,7 +616,7 @@

diff --git a/src/main/resources/static/docs/notification.html b/src/main/resources/static/docs/notification.html index d4ddae0c..4cd1aa89 100644 --- a/src/main/resources/static/docs/notification.html +++ b/src/main/resources/static/docs/notification.html @@ -513,7 +513,7 @@

응답

From fda78e1d8aced55a0891b10883c2ec9ee1c19829 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Sun, 3 Dec 2023 18:56:07 +0900 Subject: [PATCH 171/185] =?UTF-8?q?fix:=20date=20equals=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20(#239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/application/room/SearchService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index 20f12aae..a4136358 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -277,7 +277,7 @@ private List getTodayCertificateRankResponses(Long completedMembers(dailyMemberCertifications, members, certifications, participants, date, knocks, inventories)); - if (clockHolder.date() == date) { + if (clockHolder.date().equals(date)) { responses.addAll(uncompletedMembers(dailyMemberCertifications, members, participants, date, knocks, inventories)); } From a4a14ff2712ac4c9c0ff5c8b7296a23c2f70aff3 Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Sun, 3 Dec 2023 20:46:00 +0900 Subject: [PATCH 172/185] =?UTF-8?q?feat:=20exception=20AOP=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=B6=94=EA=B0=80=20(#241)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ExceptionHandler AOP 적용 * refactor: 수정 * refactor: checkstyle 적용 --- .../moabam/global/auth/filter/CorsFilter.java | 1 - .../moabam/global/common/util/LogAspect.java | 24 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/moabam/global/common/util/LogAspect.java diff --git a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java index 70508dbd..5b63d3db 100644 --- a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java @@ -45,7 +45,6 @@ protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServl throw new UnauthorizedException(ErrorMessage.INVALID_REQUEST_URL); } } catch (UnauthorizedException unauthorizedException) { - log.error("{}, {}", httpServletRequest.getHeader("referer"), allowOriginsConfig.origin()); handlerExceptionResolver.resolveException(httpServletRequest, httpServletResponse, null, unauthorizedException); diff --git a/src/main/java/com/moabam/global/common/util/LogAspect.java b/src/main/java/com/moabam/global/common/util/LogAspect.java new file mode 100644 index 00000000..6e5821d4 --- /dev/null +++ b/src/main/java/com/moabam/global/common/util/LogAspect.java @@ -0,0 +1,24 @@ +package com.moabam.global.common.util; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.stereotype.Component; + +import lombok.extern.slf4j.Slf4j; + +@Aspect +@Slf4j +@Component +public class LogAspect { + + @Around("execution(* com.moabam.global.error.handler.GlobalExceptionHandler.*(..))") + public Object printExceptionLog(ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + Exception exception = (Exception)args[0]; + + log.error("===== EXCEPTION LOG =====", exception); + + return joinPoint.proceed(); + } +} From 8917d5b8eb88811c2b62d6d1cbe878defb3e067c Mon Sep 17 00:00:00 2001 From: Youngmyung Kim <83266154+ymkim97@users.noreply.github.com> Date: Sun, 3 Dec 2023 21:43:30 +0900 Subject: [PATCH 173/185] =?UTF-8?q?refactor:=20=EB=B0=A9,=20filter,=20aop?= =?UTF-8?q?=20=EC=88=98=EC=A0=95=20(#243)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 방 상세 페이지 버그 수정 * refactor: 필터, AOP 수정 --- .../api/application/room/SearchService.java | 1 + .../moabam/global/auth/filter/CorsFilter.java | 11 ++++++--- .../moabam/global/common/util/LogAspect.java | 6 ++++- .../global/common/util/SystemClockHolder.java | 2 ++ .../MemberAuthorizeControllerTest.java | 2 +- .../api/presentation/RoomControllerTest.java | 10 +++----- .../support/common/TestClockHolder.java | 24 +++++++++++++++++++ .../common/WithoutFilterSupporter.java | 2 +- 8 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 src/test/java/com/moabam/support/common/TestClockHolder.java diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index a4136358..71910df2 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -328,6 +328,7 @@ private List uncompletedMembers( List responses = new ArrayList<>(); List allMemberIds = participants.stream() + .filter(p -> p.getDeletedAt() == null) .map(Participant::getMemberId) .distinct() .collect(Collectors.toList()); diff --git a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java index 5b63d3db..bcd7e4d8 100644 --- a/src/main/java/com/moabam/global/auth/filter/CorsFilter.java +++ b/src/main/java/com/moabam/global/auth/filter/CorsFilter.java @@ -37,11 +37,10 @@ public class CorsFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, FilterChain filterChain) throws ServletException, IOException { - String refer = httpServletRequest.getHeader("referer"); - String origin = secureMatch(refer); + String refer = getReferer(httpServletRequest); try { - if (Objects.isNull(origin)) { + if (Objects.isNull(refer)) { throw new UnauthorizedException(ErrorMessage.INVALID_REQUEST_URL); } } catch (UnauthorizedException unauthorizedException) { @@ -51,6 +50,8 @@ protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServl 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); @@ -64,6 +65,10 @@ protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServl 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); } diff --git a/src/main/java/com/moabam/global/common/util/LogAspect.java b/src/main/java/com/moabam/global/common/util/LogAspect.java index 6e5821d4..54b0eddc 100644 --- a/src/main/java/com/moabam/global/common/util/LogAspect.java +++ b/src/main/java/com/moabam/global/common/util/LogAspect.java @@ -5,6 +5,8 @@ import org.aspectj.lang.annotation.Aspect; import org.springframework.stereotype.Component; +import com.moabam.global.error.exception.UnauthorizedException; + import lombok.extern.slf4j.Slf4j; @Aspect @@ -17,7 +19,9 @@ public Object printExceptionLog(ProceedingJoinPoint joinPoint) throws Throwable Object[] args = joinPoint.getArgs(); Exception exception = (Exception)args[0]; - log.error("===== EXCEPTION LOG =====", exception); + if (!(exception instanceof UnauthorizedException)) { + log.error("===== EXCEPTION LOG =====", exception); + } return joinPoint.proceed(); } diff --git a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java index 79396d91..1d24cdc3 100644 --- a/src/main/java/com/moabam/global/common/util/SystemClockHolder.java +++ b/src/main/java/com/moabam/global/common/util/SystemClockHolder.java @@ -3,9 +3,11 @@ 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 diff --git a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java index 05c8a83e..be66cc08 100644 --- a/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/MemberAuthorizeControllerTest.java @@ -89,7 +89,7 @@ void setUp() { RestTemplate restTemplate = restTemplateBuilder.build(); ReflectionTestUtils.setField(oAuth2AuthorizationServerRequestService, "restTemplate", restTemplate); mockRestServiceServer = MockRestServiceServer.createServer(restTemplate); - willReturn("http://localhost").given(corsFilter).secureMatch(any()); + willReturn("http://localhost").given(corsFilter).getReferer(any()); } @DisplayName("인가 코드 받기 위한 로그인 페이지 요청") diff --git a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java index 36acce1b..f3de23a6 100644 --- a/src/test/java/com/moabam/api/presentation/RoomControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RoomControllerTest.java @@ -8,7 +8,6 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import java.time.LocalDate; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -19,11 +18,9 @@ import org.junit.jupiter.api.TestInstance; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; -import org.mockito.BDDMockito; 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.test.web.servlet.MockMvc; import org.springframework.transaction.annotation.Transactional; @@ -51,7 +48,7 @@ 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.SystemClockHolder; +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; @@ -102,8 +99,8 @@ class RoomControllerTest extends WithoutFilterSupporter { @Autowired private InventoryRepository inventoryRepository; - @SpyBean - private SystemClockHolder clockHolder; + @Autowired + private ClockHolder clockHolder; Member member; @@ -431,7 +428,6 @@ void enter_room_with_password_success() throws Exception { @Test void enter_room_with_no_password_success() throws Exception { // given - BDDMockito.given(clockHolder.times()).willReturn(LocalDateTime.of(2023, 12, 3, 14, 30, 0)); Room room = RoomFixture.room(); roomRepository.save(room); 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..48fafe3b --- /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 times().toLocalDate(); + } +} diff --git a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java index a58671cb..ea3eecad 100644 --- a/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java +++ b/src/test/java/com/moabam/support/common/WithoutFilterSupporter.java @@ -36,7 +36,7 @@ public class WithoutFilterSupporter { @BeforeEach void setUpMock() { willReturn("http://localhost:8080") - .given(corsFilter).secureMatch(any()); + .given(corsFilter).getReferer(any()); willReturn(Optional.of(PathResolver.Path.builder() .uri("/") From fc679b278170a904fc7b00ba5c9e3b702b777ae7 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Sun, 3 Dec 2023 22:03:02 +0900 Subject: [PATCH 174/185] =?UTF-8?q?hotfix:=20date=20equals=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/application/room/SearchService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/moabam/api/application/room/SearchService.java b/src/main/java/com/moabam/api/application/room/SearchService.java index 71910df2..f489d966 100644 --- a/src/main/java/com/moabam/api/application/room/SearchService.java +++ b/src/main/java/com/moabam/api/application/room/SearchService.java @@ -398,7 +398,7 @@ private List getCertifiedDatesBeforeWeek(Long roomId) { } private double calculateCompletePercentage(int certifiedMembersCount, Room room, LocalDate date) { - if (date != clockHolder.date()) { + if (!date.equals(clockHolder.date())) { return 0; } From 1e44578275535c6d2e8f22f724db962079adda52 Mon Sep 17 00:00:00 2001 From: parksey Date: Mon, 4 Dec 2023 00:29:38 +0900 Subject: [PATCH 175/185] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=EC=97=90=20=EB=8C=80=ED=95=9C=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moabam/api/application/member/MemberService.java | 4 ++-- .../api/application/ranking/RankingService.java | 11 +++++++++++ .../room/repository/ParticipantSearchRepository.java | 10 ++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index 358cf2df..c17efe9a 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -81,7 +81,7 @@ public Member findMemberToDelete(Long memberId) { @Transactional public void delete(Member member) { - List participants = participantRepository.findAllByMemberId(member.getId()); + List participants = participantSearchRepository.findAllByMemberIdParticipant(member.getId()); if (!participants.isEmpty()) { throw new BadRequestException(NEED_TO_EXIT_ALL_ROOMS); @@ -134,7 +134,7 @@ public UpdateRanking getRankingInfo(AuthMember authMember) { return MemberMapper.toUpdateRanking(member); } - @Scheduled(cron = "0 15 * * * *") + @Scheduled(cron = "0 11 * * * *") public void updateAllRanking() { List members = memberSearchRepository.findAllMembers(); List updateRankings = members.stream() diff --git a/src/main/java/com/moabam/api/application/ranking/RankingService.java b/src/main/java/com/moabam/api/application/ranking/RankingService.java index 65e620a3..651093f4 100644 --- a/src/main/java/com/moabam/api/application/ranking/RankingService.java +++ b/src/main/java/com/moabam/api/application/ranking/RankingService.java @@ -5,6 +5,8 @@ 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; @@ -50,6 +52,15 @@ public void removeRanking(RankingInfo 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); 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 index 03a7ee66..f391e4f0 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/ParticipantSearchRepository.java @@ -45,6 +45,16 @@ public List findAllByRoomId(Long roomId) { .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) From 7d348085f3895da543a74f6e90ab8ef905d7f30c Mon Sep 17 00:00:00 2001 From: parksey Date: Mon, 4 Dec 2023 00:30:13 +0900 Subject: [PATCH 176/185] =?UTF-8?q?feat:=20sql=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/mysql/initdb.d/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql index aa8bdafa..85397116 100644 --- a/infra/mysql/initdb.d/init.sql +++ b/infra/mysql/initdb.d/init.sql @@ -121,7 +121,7 @@ create table member ( id bigint not null auto_increment, social_id varchar(255) not null unique, - nickname varchar(255) not null unique, + nickname varchar(255) unique, intro varchar(30), profile_image varchar(255) not null, morning_image varchar(255) not null, From 29c5a92ef9b365ad7f251d9e88e26dd2dd0a52e8 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Mon, 4 Dec 2023 01:03:48 +0900 Subject: [PATCH 177/185] =?UTF-8?q?refactor:=20=EB=B2=B3=EC=A7=80=20init?= =?UTF-8?q?=20sql=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/mysql/initdb.d/init.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql index 85397116..a6edd500 100644 --- a/infra/mysql/initdb.d/init.sql +++ b/infra/mysql/initdb.d/init.sql @@ -15,7 +15,7 @@ create table badge ( id bigint not null auto_increment, member_id bigint not null, - type enum ('MORNING_ADULT','MORNING_BIRTH','NIGHT_ADULT','NIGHT_BIRTH') not null, + type enum ('BIRTH','LEVEL10','LEVEL50') not null, created_at datetime(6) not null, primary key (id) ); From 1ea152cd27fce7e1df3c79c0160424cc771d9527 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Mon, 4 Dec 2023 01:04:29 +0900 Subject: [PATCH 178/185] =?UTF-8?q?refactor:=20=EB=B0=A9=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=A0=95=EB=B3=B4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/moabam/api/domain/room/Room.java | 36 +++++++++++-------- .../com/moabam/api/domain/room/RoomTest.java | 8 ++--- 2 files changed, 26 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/moabam/api/domain/room/Room.java b/src/main/java/com/moabam/api/domain/room/Room.java index 5505b3d7..6817cb4d 100644 --- a/src/main/java/com/moabam/api/domain/room/Room.java +++ b/src/main/java/com/moabam/api/domain/room/Room.java @@ -32,15 +32,18 @@ @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 int LEVEL_10 = 10; - private static final int LEVEL_20 = 20; - private static final int LEVEL_30 = 30; 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 String ROOM_LEVEL_10_IMAGE = "https://image.moabam.com/moabam/default/room-level-10.png"; - private static final String ROOM_LEVEL_20_IMAGE = "https://image.moabam.com/moabam/default/room-level-20.png"; - private static final String ROOM_LEVEL_30_IMAGE = "https://image.moabam.com/moabam/default/room-level-30.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; @@ -113,23 +116,28 @@ public void levelUp() { } public void upgradeRoomImage(int level) { - if (level == LEVEL_5) { - this.roomImage = ROOM_LEVEL_5_IMAGE; + if (level == LEVEL_1) { + this.roomImage = ROOM_LEVEL_1_IMAGE; return; } - if (level == LEVEL_10) { - this.roomImage = ROOM_LEVEL_10_IMAGE; + if (level == LEVEL_2) { + this.roomImage = ROOM_LEVEL_2_IMAGE; return; } - if (level == LEVEL_20) { - this.roomImage = ROOM_LEVEL_20_IMAGE; + if (level == LEVEL_3) { + this.roomImage = ROOM_LEVEL_3_IMAGE; return; } - if (level == LEVEL_30) { - this.roomImage = ROOM_LEVEL_30_IMAGE; + if (level == LEVEL_4) { + this.roomImage = ROOM_LEVEL_4_IMAGE; + return; + } + + if (level == LEVEL_5) { + this.roomImage = ROOM_LEVEL_5_IMAGE; } } diff --git a/src/test/java/com/moabam/api/domain/room/RoomTest.java b/src/test/java/com/moabam/api/domain/room/RoomTest.java index 59a13a61..72e350df 100644 --- a/src/test/java/com/moabam/api/domain/room/RoomTest.java +++ b/src/test/java/com/moabam/api/domain/room/RoomTest.java @@ -93,10 +93,10 @@ void night_time_validate_exception(int certifyTime) { @DisplayName("레벨에 따른 이미지 업데이트") @ParameterizedTest @CsvSource({ - "5, https://image.moabam.com/moabam/default/room-level-05.png", - "10, https://image.moabam.com/moabam/default/room-level-10.png", - "20, https://image.moabam.com/moabam/default/room-level-20.png", - "30, https://image.moabam.com/moabam/default/room-level-30.png", + "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 From 8b60ff5a664773e6c738fb3e492f8475adf69dc2 Mon Sep 17 00:00:00 2001 From: ymkim97 Date: Mon, 4 Dec 2023 01:05:03 +0900 Subject: [PATCH 179/185] =?UTF-8?q?fix:=20=EB=B0=A9=EC=9D=98=20exp=20?= =?UTF-8?q?=EB=B3=B4=EB=82=B4=EB=8A=94=20=EB=B0=A9=EB=B2=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/moabam/api/application/room/RoomService.java | 2 +- .../moabam/api/application/room/mapper/RoomMapper.java | 7 +++++-- .../api/domain/room/repository/RoomRepository.java | 10 +++++++--- .../com/moabam/api/dto/room/RoomDetailsResponse.java | 3 ++- .../api/dto/room/UnJoinedRoomDetailsResponse.java | 3 ++- .../com/moabam/support/common/TestClockHolder.java | 2 +- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/moabam/api/application/room/RoomService.java b/src/main/java/com/moabam/api/application/room/RoomService.java index 5ad81780..1f2ccdce 100644 --- a/src/main/java/com/moabam/api/application/room/RoomService.java +++ b/src/main/java/com/moabam/api/application/room/RoomService.java @@ -90,7 +90,7 @@ public void modifyRoom(Long memberId, Long roomId, ModifyRoomRequest modifyRoomR @Transactional public void enterRoom(Long memberId, Long roomId, EnterRoomRequest enterRoomRequest) { - Room room = roomRepository.findWithPessimisticLockById(roomId).orElseThrow( + Room room = roomRepository.findWithPessimisticLockByIdAndDeletedAtIsNull(roomId).orElseThrow( () -> new NotFoundException(ROOM_NOT_FOUND)); validateRoomEnter(memberId, enterRoomRequest.password(), room); 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 index aee5dd5d..143fac74 100644 --- a/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java +++ b/src/main/java/com/moabam/api/application/room/mapper/RoomMapper.java @@ -9,6 +9,7 @@ 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; @@ -51,7 +52,8 @@ public static RoomDetailsResponse toRoomDetailsResponse(Long memberId, Room room .managerNickName(managerNickname) .roomImage(room.getRoomImage()) .level(room.getLevel()) - .exp(room.getExp()) + .currentExp(room.getExp()) + .totalExp(RoomExp.of(room.getLevel()).getTotalExp()) .roomType(room.getRoomType()) .certifyTime(room.getCertifyTime()) .currentUserCount(room.getCurrentUserCount()) @@ -149,7 +151,8 @@ public static UnJoinedRoomDetailsResponse toUnJoinedRoomDetails(Room room, List< .title(room.getTitle()) .roomImage(room.getRoomImage()) .level(room.getLevel()) - .exp(room.getExp()) + .currentExp(room.getExp()) + .totalExp(RoomExp.of(room.getLevel()).getTotalExp()) .roomType(room.getRoomType()) .certifyTime(room.getCertifyTime()) .currentUserCount(room.getCurrentUserCount()) 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 index b086872f..6994356c 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomRepository.java @@ -15,12 +15,13 @@ public interface RoomRepository extends JpaRepository { @Lock(LockModeType.PESSIMISTIC_WRITE) - Optional findWithPessimisticLockById(Long id); + 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% " + + "where (rm.title like %:keyword% " + "or rm.manager_nickname like %:keyword% " - + "or rt.content 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); @@ -29,6 +30,7 @@ public interface RoomRepository extends JpaRepository { + "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); @@ -38,6 +40,7 @@ List searchByKeywordAndRoomType(@Param(value = "keyword") String 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); @@ -47,6 +50,7 @@ List searchByKeywordAndRoomType(@Param(value = "keyword") String 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/dto/room/RoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java index cd019558..466ceee6 100644 --- a/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/RoomDetailsResponse.java @@ -17,7 +17,8 @@ public record RoomDetailsResponse( String managerNickName, String roomImage, int level, - int exp, + int currentExp, + int totalExp, RoomType roomType, int certifyTime, int currentUserCount, diff --git a/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java index d521ea30..3152a163 100644 --- a/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java +++ b/src/main/java/com/moabam/api/dto/room/UnJoinedRoomDetailsResponse.java @@ -13,7 +13,8 @@ public record UnJoinedRoomDetailsResponse( String title, String roomImage, int level, - int exp, + int currentExp, + int totalExp, RoomType roomType, int certifyTime, int currentUserCount, diff --git a/src/test/java/com/moabam/support/common/TestClockHolder.java b/src/test/java/com/moabam/support/common/TestClockHolder.java index 48fafe3b..aa75977d 100644 --- a/src/test/java/com/moabam/support/common/TestClockHolder.java +++ b/src/test/java/com/moabam/support/common/TestClockHolder.java @@ -19,6 +19,6 @@ public LocalDateTime times() { @Override public LocalDate date() { - return times().toLocalDate(); + return LocalDateTime.now().toLocalDate(); } } From 6de22807fc9c4deded1c07ba36af0841596a1f0b Mon Sep 17 00:00:00 2001 From: parksey Date: Mon, 4 Dec 2023 01:19:29 +0900 Subject: [PATCH 180/185] =?UTF-8?q?hotfix:=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/mysql/initdb.d/init.sql | 2 +- .../auth/AuthorizationService.java | 5 +- .../api/application/member/MemberService.java | 10 ++-- .../repository/MemberSearchRepository.java | 14 ++--- .../auth/AuthorizationServiceTest.java | 6 +-- .../domain/member/MemberRepositoryTest.java | 51 ------------------- 6 files changed, 19 insertions(+), 69 deletions(-) diff --git a/infra/mysql/initdb.d/init.sql b/infra/mysql/initdb.d/init.sql index 85397116..2ba0db66 100644 --- a/infra/mysql/initdb.d/init.sql +++ b/infra/mysql/initdb.d/init.sql @@ -15,7 +15,7 @@ create table badge ( id bigint not null auto_increment, member_id bigint not null, - type enum ('MORNING_ADULT','MORNING_BIRTH','NIGHT_ADULT','NIGHT_BIRTH') not null, + type enum ('BIRTH','LEVEL10','LEVEL50') not null, created_at datetime(6) not null, primary key (id) ); diff --git a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java index 38cbe63d..ed2d0840 100644 --- a/src/main/java/com/moabam/api/application/auth/AuthorizationService.java +++ b/src/main/java/com/moabam/api/application/auth/AuthorizationService.java @@ -100,8 +100,6 @@ public void issueServiceToken(HttpServletResponse response, PublicClaim publicCl String domain = getDomain(publicClaim.role()); response.addCookie(CookieUtils.typeCookie("Bearer", tokenConfig.getRefreshExpire(), domain)); - response.addCookie(CookieUtils - .tokenCookie("Test", publicClaim.role().name(), tokenConfig.getRefreshExpire(), domain)); response.addCookie(CookieUtils .tokenCookie("access_token", accessToken, tokenConfig.getRefreshExpire(), domain)); response.addCookie(CookieUtils @@ -139,7 +137,8 @@ public void removeToken(HttpServletRequest httpServletRequest, HttpServletRespon @Transactional public void unLinkMember(AuthMember authMember) { - Member member = memberService.findMemberToDelete(authMember.id()); + memberService.validateMemberToDelete(authMember.id()); + Member member = memberService.findMember(authMember.id()); unlinkRequest(member.getSocialId()); memberService.delete(member); } diff --git a/src/main/java/com/moabam/api/application/member/MemberService.java b/src/main/java/com/moabam/api/application/member/MemberService.java index c17efe9a..ef3a761c 100644 --- a/src/main/java/com/moabam/api/application/member/MemberService.java +++ b/src/main/java/com/moabam/api/application/member/MemberService.java @@ -73,10 +73,12 @@ public List getRoomMembers(List memberIds) { return memberRepository.findAllById(memberIds); } - @Transactional - public Member findMemberToDelete(Long memberId) { - return memberSearchRepository.findMemberNotManager(memberId) - .orElseThrow(() -> new NotFoundException(MEMBER_NOT_FOUND)); + public void validateMemberToDelete(Long memberId) { + List participants = memberSearchRepository.findParticipantByMemberId(memberId); + + if (!participants.isEmpty()) { + throw new NotFoundException(MEMBER_NOT_FOUND); + } } @Transactional 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 index 82a43a01..ea1c7024 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -11,6 +11,7 @@ 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; @@ -48,15 +49,14 @@ public Optional findMember(Long memberId, boolean isNotDeleted) { .fetchOne()); } - public Optional findMemberNotManager(Long memberId) { - return Optional.ofNullable(jpaQueryFactory - .selectFrom(member) - .leftJoin(participant).on(member.id.eq(participant.memberId)) + public List findParticipantByMemberId(Long memberId) { + return jpaQueryFactory + .selectFrom(participant) .where( - member.id.eq(memberId), - participant.isManager.isNull().or(participant.isManager.isFalse()) + participant.memberId.eq(memberId), + participant.deletedAt.isNull() ) - .fetchFirst()); + .fetch(); } public List findMemberAndBadges(Long searchId, boolean isMe) { diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index b746b6d6..5655471c 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -331,7 +331,7 @@ void unlink_success(@WithMember AuthMember authMember) { willReturn(member) .given(memberService) - .findMemberToDelete(authMember.id()); + .validateMemberToDelete(authMember.id()); doNothing().when(oAuth2AuthorizationServerRequestService) .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); @@ -345,7 +345,7 @@ void unlink_failBy_find_Member(@WithMember AuthMember authMember) { // Given + When willThrow(new NotFoundException(ErrorMessage.MEMBER_NOT_FOUND)) .given(memberService) - .findMemberToDelete(authMember.id()); + .validateMemberToDelete(authMember.id()); assertThatThrownBy(() -> authorizationService.unLinkMember(authMember)) .isInstanceOf(NotFoundException.class) @@ -360,7 +360,7 @@ void unlink_failBy_(@WithMember AuthMember authMember) { willReturn(member) .given(memberService) - .findMemberToDelete(authMember.id()); + .validateMemberToDelete(authMember.id()); willThrow(BadRequestException.class) .given(oAuth2AuthorizationServerRequestService) .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); diff --git a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java index ee18e08c..dd7f0ce4 100644 --- a/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java +++ b/src/test/java/com/moabam/api/domain/member/MemberRepositoryTest.java @@ -17,8 +17,6 @@ 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.RoomType; import com.moabam.api.domain.room.repository.ParticipantRepository; import com.moabam.api.domain.room.repository.RoomRepository; @@ -27,8 +25,6 @@ import com.moabam.support.annotation.QuerydslRepositoryTest; import com.moabam.support.fixture.BadgeFixture; import com.moabam.support.fixture.MemberFixture; -import com.moabam.support.fixture.ParticipantFixture; -import com.moabam.support.fixture.RoomFixture; import jakarta.persistence.EntityManager; import jakarta.persistence.PersistenceContext; @@ -68,53 +64,6 @@ void test() { assertThat(savedMember).isNotNull(); } - @DisplayName("회원 찾는 query 조회") - @Nested - class FindMemberTest { - - @DisplayName("회원이 방 매니저이면 에러") - @Test - void room_exist_and_manager_error() { - // given - Member member = MemberFixture.member("1111"); - memberRepository.save(member); - - Room room = RoomFixture.room(); - roomRepository.save(room); - - Participant participant = ParticipantFixture.participant(room, member.getId()); - participant.enableManager(); - participantRepository.save(participant); - - // when - Optional memberOptional = - memberSearchRepository.findMemberNotManager(member.getId()); - - // then - assertThat(memberOptional).isEmpty(); - } - - @DisplayName("매니저가 아니면 회원 조회 성공") - @Test - void room_exist_and_not_manager_success() { - // given - Room room = RoomFixture.room(); - room.changeManagerNickname("test"); - roomRepository.save(room); - - Member member = MemberFixture.member("44"); - member.changeNickName("not"); - memberRepository.save(member); - - // when - Optional memberOptional = - memberSearchRepository.findMemberNotManager(member.getId()); - - // then - assertThat(memberOptional).isNotEmpty(); - } - } - @DisplayName("회원 정보 찾는 Query") @Nested class FindMemberInfo { From 711f4d237e5d1d120d6ff5908ce978c4e5d7bb55 Mon Sep 17 00:00:00 2001 From: parksey Date: Mon, 4 Dec 2023 01:35:43 +0900 Subject: [PATCH 181/185] =?UTF-8?q?hotfix:=20=EB=9E=AD=ED=82=B9=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=BF=BC=EB=A6=AC=20=EB=B0=8F=20=EB=B0=A9?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/MemberSearchRepository.java | 2 +- .../room/repository/RoomSearchRepository.java | 3 ++- .../auth/AuthorizationServiceTest.java | 23 +------------------ 3 files changed, 4 insertions(+), 24 deletions(-) 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 index ea1c7024..6285f9bb 100644 --- a/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/member/repository/MemberSearchRepository.java @@ -34,7 +34,7 @@ public List findAllMembers() { return jpaQueryFactory .selectFrom(member) .where( - member.deletedAt.isNotNull() + member.deletedAt.isNull() ) .fetch(); } 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 index 9313115d..a815760e 100644 --- a/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java +++ b/src/main/java/com/moabam/api/domain/room/repository/RoomSearchRepository.java @@ -24,7 +24,8 @@ public List findAllWithNoOffset(RoomType roomType, Long roomId) { return jpaQueryFactory.selectFrom(room) .where( DynamicQuery.generateEq(roomType, room.roomType::eq), - DynamicQuery.generateEq(roomId, room.id::lt) + DynamicQuery.generateEq(roomId, room.id::lt), + room.deletedAt.isNull() ) .orderBy(room.id.desc()) .limit(ROOM_FIXED_SEARCH_SIZE + 1L) diff --git a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java index 5655471c..75f4c5a9 100644 --- a/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java +++ b/src/test/java/com/moabam/api/application/auth/AuthorizationServiceTest.java @@ -329,9 +329,7 @@ void unlink_success(@WithMember AuthMember authMember) { // given Member member = MemberFixture.member(); - willReturn(member) - .given(memberService) - .validateMemberToDelete(authMember.id()); + given(memberService.findMember(any())).willReturn(MemberFixture.member()); doNothing().when(oAuth2AuthorizationServerRequestService) .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); @@ -351,23 +349,4 @@ void unlink_failBy_find_Member(@WithMember AuthMember authMember) { .isInstanceOf(NotFoundException.class) .hasMessage(ErrorMessage.MEMBER_NOT_FOUND.getMessage()); } - - @DisplayName("소셜 탈퇴 요청 실패로 인한 실패") - @Test - void unlink_failBy_(@WithMember AuthMember authMember) { - // Given - Member member = MemberFixture.member(); - - willReturn(member) - .given(memberService) - .validateMemberToDelete(authMember.id()); - willThrow(BadRequestException.class) - .given(oAuth2AuthorizationServerRequestService) - .unlinkMemberRequest(eq(oauthConfig.provider().unlink()), eq(oauthConfig.client().adminKey()), any()); - - // When + Then - assertThatThrownBy(() -> authorizationService.unLinkMember(authMember)) - .isInstanceOf(BadRequestException.class) - .hasMessage(ErrorMessage.UNLINK_REQUEST_FAIL_ROLLBACK_SUCCESS.getMessage()); - } } From 0f15eba1038178a7f4ff413132b4e78cf6d68d6e Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 4 Dec 2023 01:43:13 +0900 Subject: [PATCH 182/185] =?UTF-8?q?feat:=20=EC=9A=B4=EC=98=81=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B0=B0=ED=8F=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 2 +- .github/workflows/prod-cd.yml | 173 +++++++++++++++++++++++++++++++ infra/docker-compose-prod.yml | 56 ++++++++++ infra/scripts/deploy-prod.sh | 107 +++++++++++++++++++ 4 files changed, 337 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/prod-cd.yml create mode 100644 infra/docker-compose-prod.yml create mode 100644 infra/scripts/deploy-prod.sh diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index cd8199cb..46d4968e 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -53,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} - source: "./infra/*" + source: "./infra/*, !./infra/docker-compose-prod.yml, !./infra/scripts/deploy-prod.sh" target: "/home/ubuntu/moabam" - name: 파일 세팅 diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml new file mode 100644 index 00000000..b3fbda97 --- /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/*, !./infra/docker-compose-dev.yml, !./infra/scripts/deploy-dev.sh" + 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/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/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 From fed185066bbb05d0d67783a5f894bb1053477c08 Mon Sep 17 00:00:00 2001 From: Dev Uni Date: Mon, 4 Dec 2023 01:54:52 +0900 Subject: [PATCH 183/185] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EB=B0=B0=ED=8F=AC=20cd=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/develop-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/develop-cd.yml b/.github/workflows/develop-cd.yml index 46d4968e..cd5449bf 100644 --- a/.github/workflows/develop-cd.yml +++ b/.github/workflows/develop-cd.yml @@ -53,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_DEV_INSTANCE_USERNAME }} key: ${{ secrets.EC2_DEV_INSTANCE_PRIVATE_KEY }} - source: "./infra/*, !./infra/docker-compose-prod.yml, !./infra/scripts/deploy-prod.sh" + source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-prod.sh, infra/docker-compose-dev.yml" target: "/home/ubuntu/moabam" - name: 파일 세팅 diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index b3fbda97..d00dca7c 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -53,7 +53,7 @@ jobs: port: 22 username: ${{ secrets.EC2_PROD_INSTANCE_USERNAME }} key: ${{ secrets.EC2_PROD_INSTANCE_PRIVATE_KEY }} - source: "./infra/*, !./infra/docker-compose-dev.yml, !./infra/scripts/deploy-dev.sh" + source: "infra/mysql/*, infra/nginx/*, infra/scripts/*.sh, !infra/scripts/deploy-dev.sh, infra/docker-compose-prod.yml" target: "/home/ubuntu/moabam" - name: 파일 세팅 From d4b5682b45241305f1db3a844473d694485385ee Mon Sep 17 00:00:00 2001 From: kmebin Date: Mon, 4 Dec 2023 01:59:50 +0900 Subject: [PATCH 184/185] =?UTF-8?q?fix:=20log=20AOP=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20=EB=B0=8F=20SlackExceptionHandler=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../moabam/global/common/util/LogAspect.java | 28 ---------------- .../error/handler/GlobalExceptionHandler.java | 32 +++++++++---------- .../error/handler/SlackExceptionHandler.java | 9 ++++-- 3 files changed, 23 insertions(+), 46 deletions(-) delete mode 100644 src/main/java/com/moabam/global/common/util/LogAspect.java diff --git a/src/main/java/com/moabam/global/common/util/LogAspect.java b/src/main/java/com/moabam/global/common/util/LogAspect.java deleted file mode 100644 index 54b0eddc..00000000 --- a/src/main/java/com/moabam/global/common/util/LogAspect.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.moabam.global.common.util; - -import org.aspectj.lang.ProceedingJoinPoint; -import org.aspectj.lang.annotation.Around; -import org.aspectj.lang.annotation.Aspect; -import org.springframework.stereotype.Component; - -import com.moabam.global.error.exception.UnauthorizedException; - -import lombok.extern.slf4j.Slf4j; - -@Aspect -@Slf4j -@Component -public class LogAspect { - - @Around("execution(* com.moabam.global.error.handler.GlobalExceptionHandler.*(..))") - public Object printExceptionLog(ProceedingJoinPoint joinPoint) throws Throwable { - Object[] args = joinPoint.getArgs(); - Exception exception = (Exception)args[0]; - - if (!(exception instanceof UnauthorizedException)) { - log.error("===== EXCEPTION LOG =====", exception); - } - - return joinPoint.proceed(); - } -} diff --git a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 1b2c6540..766a7e01 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -31,32 +31,32 @@ public class GlobalExceptionHandler { @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NotFoundException.class) - protected ErrorResponse handleNotFoundException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleNotFoundException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.UNAUTHORIZED) @ExceptionHandler(UnauthorizedException.class) - protected ErrorResponse handleUnauthorizedException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleUnauthorizedException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(ForbiddenException.class) - protected ErrorResponse handleForbiddenException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleForbiddenException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.CONFLICT) @ExceptionHandler(ConflictException.class) - protected ErrorResponse handleConflictException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleConflictException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(BadRequestException.class) - protected ErrorResponse handleBadRequestException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleBadRequestException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @@ -64,14 +64,14 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio FcmException.class, TossPaymentException.class }) - protected ErrorResponse handleFcmException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleFcmException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MoabamException.class) - protected ErrorResponse handleMoabamException(MoabamException moabamException) { - return new ErrorResponse(moabamException.getMessage(), null); + protected ErrorResponse handleMoabamException(MoabamException exception) { + return new ErrorResponse(exception.getMessage(), null); } @ResponseStatus(HttpStatus.BAD_REQUEST) @@ -95,7 +95,7 @@ protected ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotV @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) { + protected ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException exception) { String typeName = Optional.ofNullable(exception.getRequiredType()) .map(Class::getSimpleName) .orElse(""); @@ -106,7 +106,7 @@ public ErrorResponse handleMethodArgumentTypeMismatchException(MethodArgumentTyp @ExceptionHandler(MaxUploadSizeExceededException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) - public ErrorResponse handleMaxSizeException(MaxUploadSizeExceededException exception) { + 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/SlackExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java index 3751dc5f..b144c62b 100644 --- a/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java @@ -1,10 +1,13 @@ 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; @@ -16,9 +19,11 @@ public class SlackExceptionHandler { private final SlackService slackService; + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) - void handleException(HttpServletRequest request, Exception exception) throws Exception { + protected ErrorResponse handleException(HttpServletRequest request, Exception exception) throws Exception { slackService.send(request, exception); - throw exception; + + return new ErrorResponse(exception.getMessage(), null); } } From 90dbac74f39d02c3c9a39019d798b311a0112095 Mon Sep 17 00:00:00 2001 From: kmebin Date: Mon, 4 Dec 2023 02:00:32 +0900 Subject: [PATCH 185/185] =?UTF-8?q?chore:=20config=20=EC=97=85=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/config | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/config b/src/main/resources/config index 3aa15e1b..565a0f7b 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit 3aa15e1b92cc4573ccb5f18f120fb98ab66b48fa +Subproject commit 565a0f7b48e49dbaf78c798220fe712200c17ee5