diff --git a/.github/workflows/be-merge-dev.yml b/.github/workflows/be-merge-dev.yml index 608e8650..33b2781d 100644 --- a/.github/workflows/be-merge-dev.yml +++ b/.github/workflows/be-merge-dev.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: - branches: [ develop ] + branches: [ develop-BE ] types: [ closed ] paths: backend/** @@ -19,12 +19,15 @@ jobs: steps: - uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} - name: Set up JDK 17 uses: actions/setup-java@v3 with: java-version: '17' distribution: 'temurin' - + - name: 테스트 환경변수 설정 run: | echo "TEST_JWT_SECRET_KEY=${{ secrets.TEST_JWT_SECRET_KEY }}" >> $GITHUB_ENV diff --git a/.github/workflows/be-merge-prod.yml b/.github/workflows/be-merge-prod.yml index cbbdddd3..3ba5433e 100644 --- a/.github/workflows/be-merge-prod.yml +++ b/.github/workflows/be-merge-prod.yml @@ -18,31 +18,34 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' + - uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: 테스트 환경변수 설정 + run: | + echo "TEST_JWT_SECRET_KEY=${{ secrets.TEST_JWT_SECRET_KEY }}" >> $GITHUB_ENV + echo "TEST_JWT_EXPIRE_LENGTH=${{ secrets.TEST_JWT_EXPIRE_LENGTH }}" >> $GITHUB_ENV - - name: 테스트 환경변수 설정 - run: | - echo "TEST_JWT_SECRET_KEY=${{ secrets.TEST_JWT_SECRET_KEY }}" >> $GITHUB_ENV - echo "TEST_JWT_EXPIRE_LENGTH=${{ secrets.TEST_JWT_EXPIRE_LENGTH }}" >> $GITHUB_ENV + - name: gradlew 실행 권한 부여 + run: chmod +x gradlew + working-directory: backend - - name: gradlew 실행 권한 부여 - run: chmod +x gradlew - working-directory: backend + - name: Gradle build 시작 + run: ./gradlew clean build + working-directory: backend - - name: Gradle build 시작 - run: ./gradlew clean build - working-directory: backend - - - name: jar 파일 artifact에 업로드 - uses: actions/upload-artifact@v3 - with: - name: BackendApplication - path: backend/build/libs/mapbefine.jar + - name: jar 파일 artifact에 업로드 + uses: actions/upload-artifact@v3 + with: + name: BackendApplication + path: backend/build/libs/mapbefine.jar deploy: runs-on: [ self-hosted, prod ] diff --git a/.github/workflows/be-pull-request.yml b/.github/workflows/be-pull-request.yml index ab62a78e..cea976b9 100644 --- a/.github/workflows/be-pull-request.yml +++ b/.github/workflows/be-pull-request.yml @@ -4,7 +4,7 @@ on: workflow_dispatch: pull_request: - branches: [ main, develop ] + branches: [ main, develop-BE ] paths: backend/** permissions: @@ -18,35 +18,38 @@ jobs: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - name: Set up JDK 17 - uses: actions/setup-java@v3 - with: - java-version: '17' - distribution: 'temurin' - - - name: 테스트 환경변수 설정 - run: | - echo "TEST_JWT_SECRET_KEY=${{ secrets.TEST_JWT_SECRET_KEY }}" >> $GITHUB_ENV - echo "TEST_JWT_EXPIRE_LENGTH=${{ secrets.TEST_JWT_EXPIRE_LENGTH }}" >> $GITHUB_ENV - - - name: gradlew 실행 권한 부여 - run: chmod +x gradlew - working-directory: backend - - - name: Gradle build 시작 - run: ./gradlew clean build - working-directory: backend - - - name: 테스트 결과를 PR에 코멘트로 등록합니다 - uses: EnricoMi/publish-unit-test-result-action@v1 - if: always() - with: - files: 'backend/build/test-results/test/TEST-*.xml' - - - name: 테스트 실패 시, 실패한 코드 라인에 Check 코멘트를 등록합니다 - uses: mikepenz/action-junit-report@v3 - if: always() - with: - report_paths: '**/build/test-results/test/TEST-*.xml' - token: ${{ github.token }} + - uses: actions/checkout@v3 + with: + submodules: recursive + token: ${{ secrets.SUBMODULE_ACCESS_TOKEN }} + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + - name: 테스트 환경변수 설정 + run: | + echo "TEST_JWT_SECRET_KEY=${{ secrets.TEST_JWT_SECRET_KEY }}" >> $GITHUB_ENV + echo "TEST_JWT_EXPIRE_LENGTH=${{ secrets.TEST_JWT_EXPIRE_LENGTH }}" >> $GITHUB_ENV + + - name: gradlew 실행 권한 부여 + run: chmod +x gradlew + working-directory: backend + + - name: Gradle build 시작 + run: ./gradlew clean build + working-directory: backend + + - name: 테스트 결과를 PR에 코멘트로 등록합니다 + uses: EnricoMi/publish-unit-test-result-action@v1 + if: always() + with: + files: 'backend/build/test-results/test/TEST-*.xml' + + - name: 테스트 실패 시, 실패한 코드 라인에 Check 코멘트를 등록합니다 + uses: mikepenz/action-junit-report@v3 + if: always() + with: + report_paths: '**/build/test-results/test/TEST-*.xml' + token: ${{ github.token }} diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..caf7b497 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "backend/src/main/resources/config"] + path = backend/src/main/resources/config + url = https://github.com/map-befine-official/map-befine-config.git diff --git a/backend/.gitignore b/backend/.gitignore index ad0ff1ee..c2065bc2 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -35,6 +35,3 @@ out/ ### VS Code ### .vscode/ - -### Logging ### -.log diff --git a/backend/build.gradle b/backend/build.gradle index be07241a..c4192d94 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -28,6 +28,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation group: 'com.github.maricn', name: 'logback-slack-appender', version: '1.6.1' implementation 'mysql:mysql-connector-java:8.0.32' @@ -44,6 +45,10 @@ dependencies { testImplementation 'io.rest-assured:spring-mock-mvc' testImplementation 'org.assertj:assertj-core:3.19.0' + // S3 + implementation platform('com.amazonaws:aws-java-sdk-bom:1.11.1000') + implementation 'com.amazonaws:aws-java-sdk-s3' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.h2database:h2' diff --git a/backend/docs/README.md b/backend/docs/README.md index 54684f71..4834bd96 100644 --- a/backend/docs/README.md +++ b/backend/docs/README.md @@ -39,19 +39,19 @@ - 핀 상세 조회 -#### 유저 핀 +#### 회원 핀 -- 유저 핀 생성 +- 회원 핀 생성 -- 유저 핀 정보 수정 +- 회원 핀 정보 수정 - name, description 만 수정 가능하다. - description 은 1000자 까지만 가능하다. -- 유저 핀 삭제 +- 회원 핀 삭제 - Delete 는 Soft Delete --- -- 유저 핀 목록 조회 +- 회원 핀 목록 조회 - 페이지 네이션 (무한 스크롤, 일단 15개) diff --git a/backend/src/docs/asciidoc/admin.adoc b/backend/src/docs/asciidoc/admin.adoc new file mode 100644 index 00000000..3d2137c4 --- /dev/null +++ b/backend/src/docs/asciidoc/admin.adoc @@ -0,0 +1,29 @@ +== 관리자 기능 + +=== 전체 회원 조회 + +operation::admin-controller-test/find-all-member-details[snippets='http-request,http-response'] + +=== 회원 상세 조회 + +operation::admin-controller-test/find-member[snippets='http-request,http-response'] + +=== 회원 차단(삭제) + +operation::admin-controller-test/delete-member[snippets='http-request,http-response'] + +=== 토픽 삭제 + +operation::admin-controller-test/delete-topic[snippets='http-request,http-response'] + +=== 토픽 이미지 삭제 + +operation::admin-controller-test/delete-topic-image[snippets='http-request,http-response'] + +=== 핀 삭제 + +operation::admin-controller-test/delete-pin[snippets='http-request,http-response'] + +=== 핀 이미지 삭제 + +operation::admin-controller-test/delete-pin-image[snippets='http-request,http-response'] \ No newline at end of file diff --git a/backend/src/docs/asciidoc/auth.adoc b/backend/src/docs/asciidoc/auth.adoc new file mode 100644 index 00000000..625cf122 --- /dev/null +++ b/backend/src/docs/asciidoc/auth.adoc @@ -0,0 +1,13 @@ +== 소셜 로그인 + +=== KAKAO 로그인 URL 반환 + +operation::login-controller-test/redirection[snippets='http-request,http-response'] + +=== KAKAO 로그인 + +operation::login-controller-test/login[snippets='http-request,http-response'] + +=== 로그아웃 + +operation::login-controller-test/logout[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/bookmark.adoc b/backend/src/docs/asciidoc/bookmark.adoc index d416a87d..64acd65e 100644 --- a/backend/src/docs/asciidoc/bookmark.adoc +++ b/backend/src/docs/asciidoc/bookmark.adoc @@ -1,10 +1,10 @@ == 즐겨찾기 -=== 토픽을 유저의 즐겨찾기에 추가 +=== 토픽을 회원의 즐겨찾기에 추가 operation::bookmark-controller-test/add-topic-in-bookmark[snippets='http-request,http-response'] -=== 유저의 토픽 즐겨찾기 삭제 +=== 회원의 토픽 즐겨찾기 삭제 operation::bookmark-controller-test/delete-topic-in-bookmark[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/index.adoc b/backend/src/docs/asciidoc/index.adoc index c15272d2..fc35c312 100644 --- a/backend/src/docs/asciidoc/index.adoc +++ b/backend/src/docs/asciidoc/index.adoc @@ -13,5 +13,6 @@ include::pin.adoc[] include::atlas.adoc[] include::member.adoc[] include::permission.adoc[] -include::oauth.adoc[] +include::auth.adoc[] include::bookmark.adoc[] +include::admin.adoc[] diff --git a/backend/src/docs/asciidoc/member.adoc b/backend/src/docs/asciidoc/member.adoc index 94ffcb6e..520b3943 100644 --- a/backend/src/docs/asciidoc/member.adoc +++ b/backend/src/docs/asciidoc/member.adoc @@ -1,25 +1,29 @@ -== 유저 +== 회원 -=== 유저 목록 조회 +=== 회원 목록 조회 operation::member-controller-test/find-all-member[snippets='http-request,http-response'] -=== 유저 단일 조회 +=== 회원 단일 조회 operation::member-controller-test/find-member-by-id[snippets='http-request,http-response'] -=== 유저의 나의 지도 목록 조회 +=== 회원의 나의 지도 목록 조회 operation::member-controller-test/find-my-all-topics[snippets='http-request,http-response'] -=== 유저의 나의 핀 목록 조회 +=== 회원의 나의 핀 목록 조회 operation::member-controller-test/find-my-all-pins[snippets='http-request,http-response'] -=== 유저의 모아보기 조회 +=== 회원의 모아보기 조회 operation::member-controller-test/find-all-topics-in-atlas[snippets='http-request,http-response'] -=== 유저의 즐겨찾기 조회 +=== 회원의 즐겨찾기 조회 operation::member-controller-test/find-all-topics-in-bookmark[snippets='http-request,http-response'] + +=== 회원의 내 정보 수정 + +operation::member-controller-test/update-my-info[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/oauth.adoc b/backend/src/docs/asciidoc/oauth.adoc deleted file mode 100644 index f6aeec23..00000000 --- a/backend/src/docs/asciidoc/oauth.adoc +++ /dev/null @@ -1,9 +0,0 @@ -== 소셜 로그인 - -=== KAKAO 로그인 URL 반환 - -operation::oauth-controller-test/redirection[snippets='http-request,http-response'] - -=== KAKAO 로그인 - -operation::oauth-controller-test/login[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/permission.adoc b/backend/src/docs/asciidoc/permission.adoc index c704d7ed..bfb10520 100644 --- a/backend/src/docs/asciidoc/permission.adoc +++ b/backend/src/docs/asciidoc/permission.adoc @@ -8,10 +8,10 @@ operation::permission-controller-test/add-permission[snippets='http-request,http operation::permission-controller-test/delete-permission[snippets='http-request,http-response'] -=== 토픽에 권한을 가진 유저 목록 조회 +=== 토픽 접근 정보(권한 회원 목록 및 공개 여부) 조회 -operation::permission-controller-test/find-all-topic-permissions[snippets='http-request,http-response'] +operation::permission-controller-test/find-topic-access-detail-by-topic-id[snippets='http-request,http-response'] -=== 토픽에 권한을 가진 유저 단일 조회 +=== 토픽에 권한을 가진 회원 단일 조회 operation::permission-controller-test/find-permission-by-id[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/pin.adoc b/backend/src/docs/asciidoc/pin.adoc index 244d156e..33b15e36 100644 --- a/backend/src/docs/asciidoc/pin.adoc +++ b/backend/src/docs/asciidoc/pin.adoc @@ -8,7 +8,7 @@ operation::pin-controller-test/find-all[snippets='http-request,http-response'] operation::pin-controller-test/find-by-id[snippets='http-request,http-response'] -=== 멤버별 핀 목록 조회 +=== 회원별 핀 목록 조회 operation::pin-controller-test/find-all-pins-by-member-id[snippets='http-request,http-response'] diff --git a/backend/src/docs/asciidoc/topic.adoc b/backend/src/docs/asciidoc/topic.adoc index af191240..42356837 100644 --- a/backend/src/docs/asciidoc/topic.adoc +++ b/backend/src/docs/asciidoc/topic.adoc @@ -1,24 +1,29 @@ == 토픽 -=== 토픽 목록 조회 +=== 토픽 전체 목록 조회 operation::topic-controller-test/find-all[snippets='http-request,http-response'] -=== 토픽 인기 목록 조회 +=== 최신 토픽 목록 조회 -operation::location-controller-test/find-nearby-topics-sorted-by-pin-count[snippets='http-request,http-response'] +operation::topic-controller-test/find-all-by-order-by-updated-at-desc[snippets='http-request,http-response'] + +=== 인기 급상승 토픽 목록 조회 (즐겨찾기 기준) -=== 멤버별 토픽 목록 조회 +operation::topic-controller-test/find-all-best-topics[snippets='http-request,http-response'] + +=== 회원별 토픽 목록 조회 operation::topic-controller-test/find-all-topics-by-member-id[snippets='http-request,http-response'] +=== 주변 인기 토픽 목록 조회 (핀 개수 기준) + +operation::location-controller-test/find-nearby-topics-sorted-by-pin-count[snippets='http-request,http-response'] + === 토픽 상세 조회 operation::topic-controller-test/find-by-id[snippets='http-request,http-response'] -=== 최신 토픽 목록 조회 -operation::topic-controller-test/find-all-by-order-by-updated-at-desc[snippets='http-request,http-response'] - === 토픽 생성 operation::topic-controller-test/create[snippets='http-request,http-response'] @@ -27,7 +32,8 @@ operation::topic-controller-test/create[snippets='http-request,http-response'] operation::topic-controller-test/merge-and-create[snippets='http-request,http-response'] -== 토픽에 핀 추가 +=== 토픽에 핀 추가 + operation::topic-controller-test/copy-pin[snippets='http-request,http-response'] === 토픽 수정 @@ -38,6 +44,3 @@ operation::topic-controller-test/update[snippets='http-request,http-response'] operation::topic-controller-test/delete[snippets='http-request,http-response'] -=== 인기 급상승 토픽 조회 - -operation::topic-controller-test/find-all-best-topics[snippets='http-request,http-response'] diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java new file mode 100644 index 00000000..0dfaf518 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminCommandService.java @@ -0,0 +1,127 @@ +package com.mapbefine.mapbefine.admin.application; + +import static com.mapbefine.mapbefine.permission.exception.PermissionErrorCode.PERMISSION_FORBIDDEN_BY_NOT_ADMIN; +import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.TOPIC_NOT_FOUND; + +import com.mapbefine.mapbefine.atlas.domain.AtlasRepository; +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.bookmark.domain.BookmarkRepository; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.member.domain.Status; +import com.mapbefine.mapbefine.permission.domain.PermissionRepository; +import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.domain.PinImageRepository; +import com.mapbefine.mapbefine.pin.domain.PinRepository; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import com.mapbefine.mapbefine.topic.exception.TopicException; +import java.util.List; +import java.util.NoSuchElementException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class AdminCommandService { + + private final MemberRepository memberRepository; + private final TopicRepository topicRepository; + private final PinRepository pinRepository; + private final PinImageRepository pinImageRepository; + private final PermissionRepository permissionRepository; + private final AtlasRepository atlasRepository; + private final BookmarkRepository bookmarkRepository; + + public AdminCommandService( + MemberRepository memberRepository, + TopicRepository topicRepository, + PinRepository pinRepository, + PinImageRepository pinImageRepository, + PermissionRepository permissionRepository, + AtlasRepository atlasRepository, + BookmarkRepository bookmarkRepository + ) { + this.memberRepository = memberRepository; + this.topicRepository = topicRepository; + this.pinRepository = pinRepository; + this.pinImageRepository = pinImageRepository; + this.permissionRepository = permissionRepository; + this.atlasRepository = atlasRepository; + this.bookmarkRepository = bookmarkRepository; + } + + public void blockMember(AuthMember authMember, Long memberId) { + validateAdminPermission(authMember); + + Member member = findMemberById(memberId); + member.updateStatus(Status.BLOCKED); + + deleteAllRelatedMember(member); + } + + private void deleteAllRelatedMember(Member member) { + List pinIds = extractPinIdsByMember(member); + Long memberId = member.getId(); + + pinImageRepository.deleteAllByPinIds(pinIds); + topicRepository.deleteAllByMemberId(memberId); + pinRepository.deleteAllByMemberId(memberId); + permissionRepository.deleteAllByMemberId(memberId); + atlasRepository.deleteAllByMemberId(memberId); + bookmarkRepository.deleteAllByMemberId(memberId); + } + + private Member findMemberById(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("findMemberByAuthMember; member not found; id=" + id)); + } + + private void validateAdminPermission(AuthMember authMember) { + if (authMember.isRole(Role.ADMIN)) { + return; + } + + throw new PermissionForbiddenException(PERMISSION_FORBIDDEN_BY_NOT_ADMIN); + } + + private List extractPinIdsByMember(Member member) { + return member.getCreatedPins() + .stream() + .map(Pin::getId) + .toList(); + } + + public void deleteTopic(AuthMember authMember, Long topicId) { + validateAdminPermission(authMember); + + topicRepository.deleteById(topicId); + } + + public void deleteTopicImage(AuthMember authMember, Long topicId) { + validateAdminPermission(authMember); + + Topic topic = findTopicById(topicId); + topic.removeImage(); + } + + private Topic findTopicById(Long topicId) { + return topicRepository.findById(topicId) + .orElseThrow(() -> new TopicException.TopicNotFoundException(TOPIC_NOT_FOUND, List.of(topicId))); + } + + public void deletePin(AuthMember authMember, Long pinId) { + validateAdminPermission(authMember); + + pinRepository.deleteById(pinId); + } + + public void deletePinImage(AuthMember authMember, Long pinImageId) { + validateAdminPermission(authMember); + + pinImageRepository.deleteById(pinImageId); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminQueryService.java new file mode 100644 index 00000000..f7ca346a --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/application/AdminQueryService.java @@ -0,0 +1,61 @@ +package com.mapbefine.mapbefine.admin.application; + +import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; +import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.permission.exception.PermissionErrorCode; +import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.topic.domain.Topic; +import java.util.List; +import java.util.NoSuchElementException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class AdminQueryService { + + private final MemberRepository memberRepository; + + public AdminQueryService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public List findAllMemberDetails(AuthMember authMember) { + validateAdminPermission(authMember); + + List members = memberRepository.findAllByMemberInfoRole(Role.USER); + + return members.stream() + .map(AdminMemberResponse::from) + .toList(); + } + + private Member findMemberById(Long id) { + return memberRepository.findById(id) + .orElseThrow(() -> new NoSuchElementException("findMemberByAuthMember; member not found; id=" + id)); + } + + private void validateAdminPermission(AuthMember authMember) { + if (authMember.isRole(Role.ADMIN)) { + return; + } + + throw new PermissionForbiddenException(PermissionErrorCode.PERMISSION_FORBIDDEN_BY_NOT_ADMIN); + } + + public AdminMemberDetailResponse findMemberDetail(AuthMember authMember, Long memberId) { + validateAdminPermission(authMember); + + Member findMember = findMemberById(memberId); + List topics = findMember.getCreatedTopics(); + List pins = findMember.getCreatedPins(); + + return AdminMemberDetailResponse.of(findMember, topics, pins); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberDetailResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberDetailResponse.java new file mode 100644 index 00000000..fbd5e04d --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberDetailResponse.java @@ -0,0 +1,48 @@ +package com.mapbefine.mapbefine.admin.dto; + +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberInfo; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.dto.response.PinResponse; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; +import java.time.LocalDateTime; +import java.util.List; + +public record AdminMemberDetailResponse( + Long id, + String nickName, + String email, + String imageUrl, + List topics, + List pins, + LocalDateTime updatedAt +) { + + // TODO: 2023/09/12 topics, pins 모두 member를 통해 얻어올 수 있다. Service에서 꺼내서 넘겨줄 것인가 ? 아니면 DTO에서 꺼내올 것인가 ? + public static AdminMemberDetailResponse of( + Member member, + List topics, + List pins + ) { + MemberInfo memberInfo = member.getMemberInfo(); + List topicResponses = topics.stream() + .map(TopicResponse::fromGuestQuery) + .toList(); + List pinResponses = pins.stream() + .map(PinResponse::from) + .toList(); + + return new AdminMemberDetailResponse( + member.getId(), + memberInfo.getNickName(), + memberInfo.getEmail(), + memberInfo.getImageUrl(), + topicResponses, + pinResponses, + member.getUpdatedAt() + ); + } + + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberResponse.java new file mode 100644 index 00000000..f041f8f1 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/dto/AdminMemberResponse.java @@ -0,0 +1,27 @@ +package com.mapbefine.mapbefine.admin.dto; + +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberInfo; +import java.time.LocalDateTime; + +public record AdminMemberResponse( + Long id, + String nickName, + String email, + String imageUrl, + LocalDateTime updatedAt +) { + + public static AdminMemberResponse from(Member member) { + MemberInfo memberInfo = member.getMemberInfo(); + + return new AdminMemberResponse( + member.getId(), + memberInfo.getNickName(), + memberInfo.getEmail(), + memberInfo.getImageUrl(), + member.getUpdatedAt() + ); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/admin/presentation/AdminController.java b/backend/src/main/java/com/mapbefine/mapbefine/admin/presentation/AdminController.java new file mode 100644 index 00000000..2a34fec1 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/admin/presentation/AdminController.java @@ -0,0 +1,78 @@ +package com.mapbefine.mapbefine.admin.presentation; + +import com.mapbefine.mapbefine.admin.application.AdminCommandService; +import com.mapbefine.mapbefine.admin.application.AdminQueryService; +import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; +import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import java.util.List; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/admin") +public class AdminController { + + private final AdminQueryService adminQueryService; + private final AdminCommandService adminCommandService; + + + public AdminController(AdminQueryService adminQueryService, AdminCommandService adminCommandService) { + this.adminQueryService = adminQueryService; + this.adminCommandService = adminCommandService; + } + + @GetMapping("/members") + public ResponseEntity> findAllMembers(AuthMember authMember) { + List responses = adminQueryService.findAllMemberDetails(authMember); + + return ResponseEntity.ok(responses); + } + + @DeleteMapping("/members/{memberId}") + public ResponseEntity deleteMember(AuthMember authMember, @PathVariable Long memberId) { + adminCommandService.blockMember(authMember, memberId); + + return ResponseEntity.noContent().build(); + } + + @GetMapping("/members/{memberId}") + public ResponseEntity findMember(AuthMember authMember, @PathVariable Long memberId) { + AdminMemberDetailResponse response = adminQueryService.findMemberDetail(authMember, memberId); + + return ResponseEntity.ok(response); + } + + @DeleteMapping("/topics/{topicId}") + public ResponseEntity deleteTopic(AuthMember authMember, @PathVariable Long topicId) { + adminCommandService.deleteTopic(authMember, topicId); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/topics/{topicId}/images") + public ResponseEntity deleteTopicImage(AuthMember authMember, @PathVariable Long topicId) { + adminCommandService.deleteTopicImage(authMember, topicId); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/pins/{pinId}") + public ResponseEntity deletePin(AuthMember authMember, @PathVariable Long pinId) { + adminCommandService.deletePin(authMember, pinId); + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/pins/images/{imageId}") + public ResponseEntity deletePinImage(AuthMember authMember, @PathVariable Long imageId) { + adminCommandService.deletePinImage(authMember, imageId); + + return ResponseEntity.noContent().build(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/atlas/domain/AtlasRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/atlas/domain/AtlasRepository.java index 242d6992..bd37a9ba 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/atlas/domain/AtlasRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/atlas/domain/AtlasRepository.java @@ -10,4 +10,5 @@ public interface AtlasRepository extends JpaRepository { void deleteByMemberIdAndTopicId(Long memberId, Long topicId); + void deleteAllByMemberId(Long memberId); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/application/AuthService.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/application/AuthService.java index 538e1244..d35346de 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/application/AuthService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/application/AuthService.java @@ -1,13 +1,17 @@ package com.mapbefine.mapbefine.auth.application; +import static com.mapbefine.mapbefine.auth.exception.AuthException.AuthUnauthorizedException; + import com.mapbefine.mapbefine.auth.domain.AuthMember; import com.mapbefine.mapbefine.auth.domain.member.Admin; import com.mapbefine.mapbefine.auth.domain.member.User; +import com.mapbefine.mapbefine.auth.exception.AuthErrorCode; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; import com.mapbefine.mapbefine.topic.domain.Topic; import java.util.List; import java.util.Objects; +import java.util.Optional; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -21,11 +25,10 @@ public AuthService(MemberRepository memberRepository) { this.memberRepository = memberRepository; } - public boolean isMember(Long memberId) { - if (Objects.isNull(memberId)) { - return false; + public void validateMember(Long memberId) { + if (Objects.isNull(memberId) || !memberRepository.existsById(memberId)) { + throw new AuthUnauthorizedException(AuthErrorCode.ILLEGAL_MEMBER_ID); } - return memberRepository.existsById(memberId); } public AuthMember findAuthMemberByMemberId(Long memberId) { @@ -60,4 +63,15 @@ private List getCreatedTopics(Member member) { .toList(); } + public boolean isAdmin(Long memberId) { + if (Objects.isNull(memberId)) { + return false; + } + + Optional member = memberRepository.findById(memberId); + + return member.map(Member::isAdmin) + .orElse(false); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/application/TokenService.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/application/TokenService.java new file mode 100644 index 00000000..6c65921e --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/application/TokenService.java @@ -0,0 +1,64 @@ +package com.mapbefine.mapbefine.auth.application; + +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.ILLEGAL_TOKEN; + +import com.mapbefine.mapbefine.auth.domain.token.RefreshToken; +import com.mapbefine.mapbefine.auth.domain.token.RefreshTokenRepository; +import com.mapbefine.mapbefine.auth.dto.LoginTokens; +import com.mapbefine.mapbefine.auth.exception.AuthException.AuthUnauthorizedException; +import com.mapbefine.mapbefine.auth.infrastructure.TokenProvider; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class TokenService { + + private final TokenProvider tokenProvider; + private final RefreshTokenRepository refreshTokenRepository; + + public TokenService(TokenProvider tokenProvider, RefreshTokenRepository refreshTokenRepository) { + this.tokenProvider = tokenProvider; + this.refreshTokenRepository = refreshTokenRepository; + } + + public LoginTokens issueTokens(Long memberId) { + String accessToken = tokenProvider.createAccessToken(String.valueOf(memberId)); + String refreshToken = tokenProvider.createRefreshToken(); + + if (refreshTokenRepository.existsByMemberId(memberId)) { + refreshTokenRepository.deleteByMemberId(memberId); + } + + refreshTokenRepository.flush(); + + refreshTokenRepository.save(new RefreshToken(refreshToken, memberId)); + + return new LoginTokens(accessToken, refreshToken); + } + + public LoginTokens reissueToken(String refreshToken, String accessToken) { + tokenProvider.validateTokensForReissue(refreshToken, accessToken); + Long memberId = findMemberIdByRefreshToken(refreshToken); + + return issueTokens(memberId); + } + + private Long findMemberIdByRefreshToken(String token) { + RefreshToken refreshToken = refreshTokenRepository.findById(token) + .orElseThrow(() -> new AuthUnauthorizedException(ILLEGAL_TOKEN)); + + return refreshToken.getMemberId(); + } + + public void removeRefreshToken(String refreshToken, String accessToken) { + tokenProvider.validateTokensForRemoval(refreshToken, accessToken); + + String payload = tokenProvider.getPayload(accessToken); + Long memberId = Long.valueOf(payload); + + refreshTokenRepository.deleteByMemberId(memberId); + } + + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/AuthMember.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/AuthMember.java index c795b1ec..d27f7cc2 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/AuthMember.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/AuthMember.java @@ -1,5 +1,6 @@ package com.mapbefine.mapbefine.auth.domain; +import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.topic.domain.Topic; import java.util.List; @@ -27,6 +28,8 @@ protected AuthMember( public abstract boolean canPinCreateOrUpdate(Topic topic); + public abstract boolean isRole(Role role); + public Long getMemberId() { return memberId; } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Admin.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Admin.java index 3d97522c..f6a54648 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Admin.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Admin.java @@ -1,6 +1,7 @@ package com.mapbefine.mapbefine.auth.domain.member; import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.topic.domain.Topic; import java.util.Collections; @@ -34,4 +35,9 @@ public boolean canPinCreateOrUpdate(Topic topic) { return true; } + @Override + public boolean isRole(Role role) { + return Role.ADMIN == role; + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Guest.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Guest.java index 6c8c0edc..dcd8dbeb 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Guest.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/Guest.java @@ -1,6 +1,7 @@ package com.mapbefine.mapbefine.auth.domain.member; import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicStatus; import java.util.Collections; @@ -36,4 +37,8 @@ public boolean canPinCreateOrUpdate(Topic topic) { return false; } + @Override + public boolean isRole(Role role) { + return Role.GUEST == role; + } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/User.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/User.java index d2444d49..ed2ea7b8 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/User.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/member/User.java @@ -1,6 +1,7 @@ package com.mapbefine.mapbefine.auth.domain.member; import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicStatus; import java.util.List; @@ -54,4 +55,9 @@ private boolean hasPermission(Long topicId) { return createdTopic.contains(topicId) || topicsWithPermission.contains(topicId); } + @Override + public boolean isRole(Role role) { + return Role.USER == role; + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshToken.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshToken.java new file mode 100644 index 00000000..e58c45f0 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshToken.java @@ -0,0 +1,27 @@ +package com.mapbefine.mapbefine.auth.domain.token; + +import static lombok.AccessLevel.PROTECTED; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = PROTECTED) +@Getter +public class RefreshToken { + + @Id + private String token; + + @Column(nullable = false, unique = true) + private Long memberId; + + public RefreshToken(String token, Long memberId) { + this.token = token; + this.memberId = memberId; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshTokenRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshTokenRepository.java new file mode 100644 index 00000000..f9c0698d --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/domain/token/RefreshTokenRepository.java @@ -0,0 +1,15 @@ +package com.mapbefine.mapbefine.auth.domain.token; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; + +public interface RefreshTokenRepository extends JpaRepository { + + boolean existsByMemberId(Long memberId); + + void deleteByMemberId(Long memberId); + + @Modifying(clearAutomatically = true) + void delete(RefreshToken token); + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/AccessToken.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/AccessToken.java new file mode 100644 index 00000000..740b62b1 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/AccessToken.java @@ -0,0 +1,6 @@ +package com.mapbefine.mapbefine.auth.dto; + +public record AccessToken( + String accessToken +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/LoginTokens.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/LoginTokens.java new file mode 100644 index 00000000..5fee5e89 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/LoginTokens.java @@ -0,0 +1,11 @@ +package com.mapbefine.mapbefine.auth.dto; + +public record LoginTokens( + String accessToken, + String refreshToken +) { + + public AccessToken toAccessToken() { + return new AccessToken(accessToken); + } +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/response/LoginInfoResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/response/LoginInfoResponse.java new file mode 100644 index 00000000..24cdee73 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/dto/response/LoginInfoResponse.java @@ -0,0 +1,9 @@ +package com.mapbefine.mapbefine.auth.dto.response; + +import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; + +public record LoginInfoResponse( + String accessToken, + MemberDetailResponse member +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthErrorCode.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthErrorCode.java new file mode 100644 index 00000000..31eb8b91 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthErrorCode.java @@ -0,0 +1,23 @@ +package com.mapbefine.mapbefine.auth.exception; + +import lombok.Getter; + +@Getter +public enum AuthErrorCode { + ILLEGAL_MEMBER_ID("01100", "로그인에 실패하였습니다."), + ILLEGAL_TOKEN("01101", "로그인에 실패하였습니다."), + FORBIDDEN_ADMIN_ACCESS("01102", "로그인에 실패하였습니다."), + BLOCKING_MEMBER_ACCESS("01103", "로그인에 실패하였습니다."), + EXPIRED_TOKEN("01104", "기간이 만료된 토큰입니다."), + BAD_REQUEST_TOKEN("01005", "잘못된 요청입니다.") + ; + + private final String code; + private final String message; + + AuthErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthException.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthException.java new file mode 100644 index 00000000..4f384f9e --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/exception/AuthException.java @@ -0,0 +1,23 @@ +package com.mapbefine.mapbefine.auth.exception; + +import com.mapbefine.mapbefine.common.exception.ErrorCode; +import com.mapbefine.mapbefine.common.exception.ForbiddenException; +import com.mapbefine.mapbefine.common.exception.UnauthorizedException; + +public class AuthException { + + public static class AuthUnauthorizedException extends UnauthorizedException { + + public AuthUnauthorizedException(AuthErrorCode errorCode) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + } + + public static class AuthForbiddenException extends ForbiddenException { + + public AuthForbiddenException(AuthErrorCode errorCode) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProvider.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProvider.java index af4565f1..36a5d1ad 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProvider.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProvider.java @@ -1,22 +1,52 @@ package com.mapbefine.mapbefine.auth.infrastructure; +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.BAD_REQUEST_TOKEN; +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.EXPIRED_TOKEN; +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.ILLEGAL_TOKEN; + +import com.mapbefine.mapbefine.auth.exception.AuthException.AuthUnauthorizedException; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.Jws; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import java.util.Date; +import java.util.UUID; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @Component -public class JwtTokenProvider { - @Value("${security.jwt.token.secret-key}") - private String secretKey; - @Value("${security.jwt.token.expire-length}") - private long validityInMilliseconds; +public class JwtTokenProvider implements TokenProvider { + + private final String secretKey; + private final long accessExpirationTime; + private final long refreshExpirationTime; + + public JwtTokenProvider( + @Value("${security.jwt.token.secret-key}") + String secretKey, + @Value("${security.jwt.token.access-expire-length}") + long accessExpirationTime, + @Value("${security.jwt.token.refresh-expire-length}") + long refreshExpirationTime + ) { + this.secretKey = secretKey; + this.accessExpirationTime = accessExpirationTime; + this.refreshExpirationTime = refreshExpirationTime; + } + + public String createAccessToken(String payload) { + return createToken(payload, accessExpirationTime); + } + + public String createRefreshToken() { + UUID payload = UUID.randomUUID(); - public String createToken(String payload) { + return createToken(payload.toString(), refreshExpirationTime); + } + + private String createToken(String payload, Long validityInMilliseconds) { Claims claims = Jwts.claims().setSubject(payload); Date now = new Date(); Date validity = new Date(now.getTime() + validityInMilliseconds); @@ -33,14 +63,40 @@ public String getPayload(String token) { return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); } - public boolean validateToken(String token) { + public void validateTokensForReissue(String refreshToken, String accessToken) { + boolean canReissueAccessToken = !isExpired(refreshToken) && isExpired(accessToken); + if (canReissueAccessToken) { + return; + } + throw new AuthUnauthorizedException(BAD_REQUEST_TOKEN); + } + + public void validateTokensForRemoval(String refreshToken, String accessToken) { + boolean canRemoveRefreshToken = !isExpired(refreshToken) && !isExpired(accessToken); + if (canRemoveRefreshToken) { + return; + } + throw new AuthUnauthorizedException(BAD_REQUEST_TOKEN); + } + + public void validateAccessToken(String accessToken) { + if (isExpired(accessToken)) { + throw new AuthUnauthorizedException(EXPIRED_TOKEN); + } + } + + private boolean isExpired(String token) { try { Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + Date expiration = claims.getBody().getExpiration(); - return !claims.getBody().getExpiration().before(new Date()); + return expiration.before(new Date()); + } catch (ExpiredJwtException e) { + return true; } catch (JwtException | IllegalArgumentException e) { - return false; + throw new AuthUnauthorizedException(ILLEGAL_TOKEN); } } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/TokenProvider.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/TokenProvider.java new file mode 100644 index 00000000..cc9f04cd --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/infrastructure/TokenProvider.java @@ -0,0 +1,16 @@ +package com.mapbefine.mapbefine.auth.infrastructure; + +public interface TokenProvider { + + String createAccessToken(String payload); + + String createRefreshToken(); + + String getPayload(String token); + + void validateTokensForReissue(String refreshToken, String accessToken); + + void validateTokensForRemoval(String refreshToken, String accessToken); + + void validateAccessToken(String accessToken); +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/auth/presentation/LoginController.java b/backend/src/main/java/com/mapbefine/mapbefine/auth/presentation/LoginController.java new file mode 100644 index 00000000..58a608e4 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/auth/presentation/LoginController.java @@ -0,0 +1,96 @@ +package com.mapbefine.mapbefine.auth.presentation; + +import static org.springframework.http.HttpHeaders.SET_COOKIE; + +import com.mapbefine.mapbefine.auth.application.TokenService; +import com.mapbefine.mapbefine.auth.dto.AccessToken; +import com.mapbefine.mapbefine.auth.dto.LoginTokens; +import com.mapbefine.mapbefine.auth.dto.response.LoginInfoResponse; +import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; +import com.mapbefine.mapbefine.oauth.application.OauthService; +import com.mapbefine.mapbefine.oauth.domain.OauthServerType; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.CookieValue; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class LoginController { + + public static final int TWO_WEEKS = 14 * 24 * 60 * 60; + + private final TokenService tokenService; + private final OauthService oauthService; + + public LoginController(TokenService tokenService, OauthService oauthService) { + this.tokenService = tokenService; + this.oauthService = oauthService; + } + + @GetMapping("/oauth/{oauthServerType}") + public ResponseEntity redirection( + @PathVariable OauthServerType oauthServerType, + HttpServletResponse response + ) throws IOException { + String redirectUrl = oauthService.getAuthCodeRequestUrl(oauthServerType); + response.sendRedirect(redirectUrl); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/oauth/login/{oauthServerType}") + public ResponseEntity login( + @PathVariable OauthServerType oauthServerType, + @RequestParam String code, + HttpServletResponse response + ) { + MemberDetailResponse memberDetail = oauthService.login(oauthServerType, code); + + LoginTokens loginTokens = tokenService.issueTokens(memberDetail.id()); + ResponseCookie cookie = createCookie(loginTokens.refreshToken()); + response.addHeader(SET_COOKIE, cookie.toString()); + + return ResponseEntity.ok(new LoginInfoResponse(loginTokens.accessToken(), memberDetail)); + } + + private ResponseCookie createCookie(String refreshToken) { + return ResponseCookie.from("refresh-token", refreshToken) + .httpOnly(true) + .maxAge(TWO_WEEKS) + .sameSite("None") + .secure(true) + .path("/") + .build(); + } + + @PostMapping("/refresh-token") + public ResponseEntity reissueTokens( + @CookieValue("refresh-token") String refreshToken, + @RequestBody AccessToken request, + HttpServletResponse response + ) { + LoginTokens loginTokens = tokenService.reissueToken(refreshToken, request.accessToken()); + ResponseCookie cookie = createCookie(loginTokens.refreshToken()); + response.addHeader(SET_COOKIE, cookie.toString()); + + return ResponseEntity.ok(loginTokens.toAccessToken()); + } + + @PostMapping("/logout") + public ResponseEntity logout( + @CookieValue("refresh-token") String refreshToken, + @RequestBody AccessToken request + ) { + tokenService.removeRefreshToken(refreshToken, request.accessToken()); + + return ResponseEntity.noContent().build(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/config/AuthConfig.java b/backend/src/main/java/com/mapbefine/mapbefine/common/config/AuthConfig.java index 02cb0062..c972f561 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/common/config/AuthConfig.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/config/AuthConfig.java @@ -1,5 +1,6 @@ package com.mapbefine.mapbefine.common.config; +import com.mapbefine.mapbefine.common.interceptor.AdminAuthInterceptor; import com.mapbefine.mapbefine.common.interceptor.AuthInterceptor; import com.mapbefine.mapbefine.common.resolver.AuthArgumentResolver; import java.util.List; @@ -11,20 +12,26 @@ @Configuration public class AuthConfig implements WebMvcConfigurer { + private final AdminAuthInterceptor adminAuthInterceptor; private final AuthInterceptor authInterceptor; private final AuthArgumentResolver authArgumentResolver; public AuthConfig( + AdminAuthInterceptor adminAuthInterceptor, AuthInterceptor authInterceptor, AuthArgumentResolver authArgumentResolver ) { + this.adminAuthInterceptor = adminAuthInterceptor; this.authInterceptor = authInterceptor; this.authArgumentResolver = authArgumentResolver; } @Override public void addInterceptors(InterceptorRegistry registry) { - registry.addInterceptor(authInterceptor); + registry.addInterceptor(authInterceptor) + .excludePathPatterns("/admin/**"); + registry.addInterceptor(adminAuthInterceptor) + .addPathPatterns("/admin/**"); } @Override diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/config/HibernateConfig.java b/backend/src/main/java/com/mapbefine/mapbefine/common/config/HibernateConfig.java new file mode 100644 index 00000000..8bb66b0c --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/config/HibernateConfig.java @@ -0,0 +1,24 @@ +package com.mapbefine.mapbefine.common.config; + +import com.mapbefine.mapbefine.common.filter.QueryInspector; +import org.hibernate.cfg.AvailableSettings; +import org.springframework.boot.autoconfigure.orm.jpa.HibernatePropertiesCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class HibernateConfig { + + private final QueryInspector queryInspector; + + public HibernateConfig(QueryInspector queryInspector) { + this.queryInspector = queryInspector; + } + + @Bean + public HibernatePropertiesCustomizer hibernatePropertiesCustomizer() { + return hibernateProperties -> + hibernateProperties.put(AvailableSettings.STATEMENT_INSPECTOR, queryInspector); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/config/S3Config.java b/backend/src/main/java/com/mapbefine/mapbefine/common/config/S3Config.java new file mode 100644 index 00000000..2cb668ab --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/config/S3Config.java @@ -0,0 +1,41 @@ +package com.mapbefine.mapbefine.common.config; + +import com.amazonaws.auth.InstanceProfileCredentialsProvider; +import com.amazonaws.regions.Regions; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class S3Config { + + private static final Logger log = LoggerFactory.getLogger(S3Config.class); + @Value("${server.tomcat.accept-count}") + private int acceptCount; + @Value("${server.tomcat.max-connections}") + private int maxConnections; + @Value("${server.tomcat.threads.max}") + private int threadsMax; + + @Bean + public InstanceProfileCredentialsProvider instanceProfileCredentialsProvider() { + return InstanceProfileCredentialsProvider.getInstance(); + } + + @Bean + public AmazonS3 amazonS3() { + log.debug("tomcat acceptCount : {}", acceptCount); + log.debug("tomcat maxConnections : {}", maxConnections); + log.debug("tomcat threadsMax : {}", threadsMax); + + return AmazonS3ClientBuilder.standard() + .withRegion(Regions.AP_NORTHEAST_2) + .withCredentials(instanceProfileCredentialsProvider()) + .build(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/config/WebConfig.java b/backend/src/main/java/com/mapbefine/mapbefine/common/config/WebConfig.java index 635d99ae..1ffbcace 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/common/config/WebConfig.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/config/WebConfig.java @@ -1,5 +1,8 @@ package com.mapbefine.mapbefine.common.config; +import static org.springframework.http.HttpHeaders.LOCATION; +import static org.springframework.http.HttpHeaders.SET_COOKIE; + import com.mapbefine.mapbefine.common.converter.OauthServerTypeConverter; import org.springframework.context.annotation.Configuration; import org.springframework.format.FormatterRegistry; @@ -13,8 +16,10 @@ public class WebConfig implements WebMvcConfigurer { public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000", "https://mapbefine.kro.kr", "https://mapbefine.com") + .allowedHeaders("*") .allowedMethods("*") - .exposedHeaders("Location"); + .allowCredentials(true) + .exposedHeaders(LOCATION, SET_COOKIE); } @Override diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/entity/BaseTimeEntity.java b/backend/src/main/java/com/mapbefine/mapbefine/common/entity/BaseTimeEntity.java index f8a145e2..ad3ad481 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/common/entity/BaseTimeEntity.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/entity/BaseTimeEntity.java @@ -2,6 +2,7 @@ import static lombok.AccessLevel.PROTECTED; +import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; import jakarta.persistence.MappedSuperclass; import java.time.LocalDateTime; @@ -18,6 +19,7 @@ public abstract class BaseTimeEntity { @CreatedDate + @Column(updatable = false) private LocalDateTime createdAt; @LastModifiedDate private LocalDateTime updatedAt; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyLoggingFilter.java b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyLoggingFilter.java new file mode 100644 index 00000000..48fe8fb0 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyLoggingFilter.java @@ -0,0 +1,45 @@ +package com.mapbefine.mapbefine.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +public class LatencyLoggingFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(LatencyLoggingFilter.class); + + private final LatencyRecorder latencyRecorder; + private final QueryInspector queryInspector; + + public LatencyLoggingFilter(LatencyRecorder latencyRecorder, QueryInspector queryInspector) { + this.latencyRecorder = latencyRecorder; + this.queryInspector = queryInspector; + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + latencyRecorder.start(); + + filterChain.doFilter(request, response); + + double latencyForSeconds = latencyRecorder.getLatencyForSeconds(); + int queryCount = queryInspector.getQueryCount(); + String requestURI = request.getRequestURI(); + + log.info("Latency : {}s, Query count : {}, Request URI : {}", latencyForSeconds, queryCount, requestURI); + MDC.clear(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyRecorder.java b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyRecorder.java new file mode 100644 index 00000000..204085ff --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/LatencyRecorder.java @@ -0,0 +1,23 @@ +package com.mapbefine.mapbefine.common.filter; + +import org.springframework.stereotype.Component; + +@Component +public class LatencyRecorder { + + private final ThreadLocal threadLocal = new ThreadLocal<>(); + + public void start() { + threadLocal.set(System.currentTimeMillis()); + } + + public double getLatencyForSeconds() { + long start = threadLocal.get(); + long end = System.currentTimeMillis(); + + threadLocal.remove(); + + return (end - start) / 1000d; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryCounter.java b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryCounter.java new file mode 100644 index 00000000..8ebdc614 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryCounter.java @@ -0,0 +1,20 @@ +package com.mapbefine.mapbefine.common.filter; + +import org.springframework.stereotype.Component; +import org.springframework.web.context.annotation.RequestScope; + +@Component +@RequestScope +public class QueryCounter { + + private int count = 0; + + public void increase() { + count++; + } + + public int getCount() { + return count; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryInspector.java b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryInspector.java new file mode 100644 index 00000000..fc33ab29 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/filter/QueryInspector.java @@ -0,0 +1,33 @@ +package com.mapbefine.mapbefine.common.filter; + +import org.hibernate.resource.jdbc.spi.StatementInspector; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; + +@Component +public class QueryInspector implements StatementInspector { + + private final QueryCounter queryCounter; + + public QueryInspector(QueryCounter queryCounter) { + this.queryCounter = queryCounter; + } + + @Override + public String inspect(String sql) { + if (isInRequestScope()) { + queryCounter.increase(); + } + + return sql; + } + + private boolean isInRequestScope() { + return RequestContextHolder.getRequestAttributes() != null; + } + + public int getQueryCount() { + return queryCounter.getCount(); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AdminAuthInterceptor.java b/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AdminAuthInterceptor.java new file mode 100644 index 00000000..7a7cb1bb --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AdminAuthInterceptor.java @@ -0,0 +1,70 @@ +package com.mapbefine.mapbefine.common.interceptor; + +import com.mapbefine.mapbefine.auth.application.AuthService; +import com.mapbefine.mapbefine.auth.dto.AuthInfo; +import com.mapbefine.mapbefine.auth.exception.AuthErrorCode; +import com.mapbefine.mapbefine.auth.exception.AuthException; +import com.mapbefine.mapbefine.auth.infrastructure.AuthorizationExtractor; +import com.mapbefine.mapbefine.auth.infrastructure.TokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.Objects; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +@Component +public class AdminAuthInterceptor implements HandlerInterceptor { + + private final AuthorizationExtractor authorizationExtractor; + private final AuthService authService; + private final TokenProvider tokenProvider; + + public AdminAuthInterceptor( + AuthorizationExtractor authorizationExtractor, + AuthService authService, + TokenProvider tokenProvider + ) { + this.authorizationExtractor = authorizationExtractor; + this.authService = authService; + this.tokenProvider = tokenProvider; + } + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(handler instanceof HandlerMethod)) { + return true; + } + + Long memberId = extractMemberIdFromToken(request); + + validateAdmin(memberId); + request.setAttribute("memberId", memberId); + + return true; + } + + private Long extractMemberIdFromToken(HttpServletRequest request) { + AuthInfo authInfo = authorizationExtractor.extract(request); + if (Objects.isNull(authInfo)) { + return null; + } + tokenProvider.validateAccessToken(authInfo.accessToken()); + + return Long.parseLong(tokenProvider.getPayload(authInfo.accessToken())); + } + + private void validateAdmin(Long memberId) { + if (authService.isAdmin(memberId)) { + return; + } + + throw new AuthException.AuthForbiddenException(AuthErrorCode.FORBIDDEN_ADMIN_ACCESS); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AuthInterceptor.java b/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AuthInterceptor.java index 32a52d38..7ee91245 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AuthInterceptor.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/common/interceptor/AuthInterceptor.java @@ -4,13 +4,12 @@ import com.mapbefine.mapbefine.auth.domain.AuthMember; import com.mapbefine.mapbefine.auth.dto.AuthInfo; import com.mapbefine.mapbefine.auth.infrastructure.AuthorizationExtractor; -import com.mapbefine.mapbefine.auth.infrastructure.JwtTokenProvider; -import com.mapbefine.mapbefine.common.exception.ErrorCode; -import com.mapbefine.mapbefine.common.exception.UnauthorizedException; +import com.mapbefine.mapbefine.auth.infrastructure.TokenProvider; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.Arrays; import java.util.Objects; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Component; import org.springframework.web.method.HandlerMethod; import org.springframework.web.servlet.HandlerInterceptor; @@ -18,30 +17,26 @@ @Component public class AuthInterceptor implements HandlerInterceptor { - private static final String UNAUTHORIZED_ERROR_MESSAGE = "로그인에 실패하였습니다."; - private static final ErrorCode ILLEGAL_MEMBER_ID = new ErrorCode("03100", UNAUTHORIZED_ERROR_MESSAGE); - private static final ErrorCode ILLEGAL_TOKEN = new ErrorCode("03101", UNAUTHORIZED_ERROR_MESSAGE); - private final AuthorizationExtractor authorizationExtractor; private final AuthService authService; - private final JwtTokenProvider jwtTokenProvider; + private final TokenProvider tokenProvider; public AuthInterceptor( AuthorizationExtractor authorizationExtractor, AuthService authService, - JwtTokenProvider jwtTokenProvider + TokenProvider tokenProvider ) { this.authorizationExtractor = authorizationExtractor; this.authService = authService; - this.jwtTokenProvider = jwtTokenProvider; + this.tokenProvider = tokenProvider; } @Override public boolean preHandle( - HttpServletRequest request, - HttpServletResponse response, - Object handler - ) throws Exception { + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { if (!(handler instanceof HandlerMethod handlerMethod)) { return true; } @@ -52,7 +47,7 @@ public boolean preHandle( Long memberId = extractMemberIdFromToken(request); if (isLoginRequired((HandlerMethod) handler)) { - validateMember(memberId); + authService.validateMember(memberId); } request.setAttribute("memberId", memberId); @@ -60,14 +55,6 @@ public boolean preHandle( return true; } - private void validateMember(Long memberId) { - if (authService.isMember(memberId)) { - return; - } - - throw new UnauthorizedException(ILLEGAL_MEMBER_ID); - } - private boolean isAuthMemberNotRequired(HandlerMethod handlerMethod) { return Arrays.stream(handlerMethod.getMethodParameters()) .noneMatch(parameter -> parameter.getParameterType().equals(AuthMember.class)); @@ -84,11 +71,9 @@ private Long extractMemberIdFromToken(HttpServletRequest request) { if (Objects.isNull(authInfo)) { return null; } - String accessToken = authInfo.accessToken(); - if (!jwtTokenProvider.validateToken(accessToken)) { - throw new UnauthorizedException(ILLEGAL_TOKEN); - } - return Long.parseLong(jwtTokenProvider.getPayload(accessToken)); + tokenProvider.validateAccessToken(authInfo.accessToken()); + + return Long.parseLong(tokenProvider.getPayload(authInfo.accessToken())); } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/application/ImageService.java b/backend/src/main/java/com/mapbefine/mapbefine/image/application/ImageService.java new file mode 100644 index 00000000..3a9d0782 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/application/ImageService.java @@ -0,0 +1,11 @@ +package com.mapbefine.mapbefine.image.application; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +public interface ImageService { + + String upload(MultipartFile multipartFile); + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/application/S3ImageService.java b/backend/src/main/java/com/mapbefine/mapbefine/image/application/S3ImageService.java new file mode 100644 index 00000000..e9a9baf5 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/application/S3ImageService.java @@ -0,0 +1,43 @@ +package com.mapbefine.mapbefine.image.application; + +import com.mapbefine.mapbefine.image.domain.S3Client; +import com.mapbefine.mapbefine.image.domain.UploadFile; +import java.io.IOException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Profile("!test") +public class S3ImageService implements ImageService { + + @Value("${prefix.upload.path}") + private String prefixUploadPath; + private final S3Client s3Client; + + public S3ImageService(S3Client s3Client) { + this.s3Client = s3Client; + } + + @Override + public String upload(MultipartFile multipartFile) { + try { + UploadFile uploadFile = UploadFile.from(multipartFile); + s3Client.upload(uploadFile); + return getUploadPath(uploadFile); + } catch (IOException exception) { + throw new RuntimeException(exception); + } + } + + private String getUploadPath(UploadFile uploadFile) { + return String.join( + "/", + prefixUploadPath, + uploadFile.getOriginalFilename() + ); + } + +} + diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageExtension.java b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageExtension.java new file mode 100644 index 00000000..90134906 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageExtension.java @@ -0,0 +1,34 @@ +package com.mapbefine.mapbefine.image.domain; + +import static com.mapbefine.mapbefine.image.exception.ImageErrorCode.ILLEGAL_IMAGE_FILE_EXTENSION; + +import com.mapbefine.mapbefine.image.exception.ImageException.ImageBadRequestException; +import java.util.Arrays; + +public enum ImageExtension { + + JPEG(".jpeg"), + JPG(".jpg"), + JFIF(".jfif"), + PNG(".png"), + SVG(".svg"), + ; + + private final String extension; + + ImageExtension(final String extension) { + this.extension = extension; + } + + public static ImageExtension from(String imageFileName) { + return Arrays.stream(values()) + .filter(imageExtension -> imageFileName.endsWith(imageExtension.getExtension())) + .findFirst() + .orElseThrow(() -> new ImageBadRequestException(ILLEGAL_IMAGE_FILE_EXTENSION)); + } + + public String getExtension() { + return extension; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageName.java b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageName.java new file mode 100644 index 00000000..63a011a5 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/ImageName.java @@ -0,0 +1,32 @@ +package com.mapbefine.mapbefine.image.domain; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +public class ImageName { + + private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSSSSS"); + + private final String fileName; + + private ImageName(String fileName) { + this.fileName = fileName; + } + + public static ImageName from(String originalFileName) { + String fileName = FORMATTER.format(LocalDateTime.now()); + String extension = extractExtension(originalFileName); + + return new ImageName(fileName + extension); + } + + private static String extractExtension(String originalFileName) { + return ImageExtension.from(originalFileName) + .getExtension(); + } + + public String getFileName() { + return fileName; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/domain/S3Client.java b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/S3Client.java new file mode 100644 index 00000000..595cebe5 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/S3Client.java @@ -0,0 +1,53 @@ +package com.mapbefine.mapbefine.image.domain; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.DeleteObjectRequest; +import com.amazonaws.services.s3.model.PutObjectRequest; +import java.io.File; +import java.io.IOException; +import java.util.Objects; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class S3Client { + + @Value("${s3.bucket}") + private String bucket; + private final AmazonS3 amazonS3; + + public S3Client(AmazonS3 amazonS3) { + this.amazonS3 = amazonS3; + } + + public void upload(MultipartFile multipartFile) throws IOException { + File tempFile = null; + + try { + tempFile = File.createTempFile("upload_", ".tmp"); + multipartFile.transferTo(tempFile); + amazonS3.putObject(new PutObjectRequest( + bucket, + multipartFile.getOriginalFilename(), + tempFile + )); + } catch (IOException exception) { + throw new IOException(exception); + } finally { + removeTempFileIfExists(tempFile); + } + } + + private void removeTempFileIfExists(File tempFile) { + if (Objects.nonNull(tempFile) && tempFile.exists()) { + tempFile.delete(); + } + } + + public void delete(String key) { + // TODO 현재는 일단 기능만 만들어놓고, API 는 만들어놓지 않았습니다 회의를 통해서 결정해야 할 사항이 있는 것 같아서요! + amazonS3.deleteObject(new DeleteObjectRequest(bucket, key)); + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/domain/UploadFile.java b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/UploadFile.java new file mode 100644 index 00000000..b8199f2b --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/domain/UploadFile.java @@ -0,0 +1,81 @@ +package com.mapbefine.mapbefine.image.domain; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import org.springframework.core.io.Resource; +import org.springframework.web.multipart.MultipartFile; + +public class UploadFile implements MultipartFile { + + private final String fileName; + private final byte[] bytes; + + private UploadFile( + String fileName, + byte[] bytes + ) { + this.fileName = fileName; + this.bytes = bytes; + } + + public static UploadFile from( + MultipartFile multipartFile + ) throws IOException { + ImageName imageName = ImageName.from(multipartFile.getOriginalFilename()); + byte[] multipartFileBytes = multipartFile.getBytes(); + + return new UploadFile(imageName.getFileName(), multipartFileBytes); + } + + @Override + public String getName() { + return fileName; + } + + @Override + public String getOriginalFilename() { + return fileName; + } + + @Override + public String getContentType() { + return null; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public long getSize() { + return 0; + } + + @Override + public byte[] getBytes() { + return bytes; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(bytes); + } + + @Override + public Resource getResource() { + return MultipartFile.super + .getResource(); + } + + @Override + public void transferTo(File dest) throws IOException, IllegalStateException { + try (FileOutputStream fileOutputStream = new FileOutputStream(dest)) { + fileOutputStream.write(bytes); + } + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageErrorCode.java b/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageErrorCode.java new file mode 100644 index 00000000..c92fd970 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageErrorCode.java @@ -0,0 +1,20 @@ +package com.mapbefine.mapbefine.image.exception; + +import lombok.Getter; + +@Getter +public enum ImageErrorCode { + + ILLEGAL_IMAGE_FILE_EXTENSION("10000", "지원하지 않는 이미지 파일입니다."), + IMAGE_FILE_IS_NULL("10001", "이미지가 선택되지 않았습니다.") + ; + + private final String code; + private final String message; + + ImageErrorCode(String code, String message) { + this.code = code; + this.message = message; + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageException.java b/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageException.java new file mode 100644 index 00000000..680140d6 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/image/exception/ImageException.java @@ -0,0 +1,16 @@ +package com.mapbefine.mapbefine.image.exception; + +import com.mapbefine.mapbefine.common.exception.BadRequestException; +import com.mapbefine.mapbefine.common.exception.ErrorCode; + +public class ImageException { + + public static class ImageBadRequestException extends BadRequestException { + + public ImageBadRequestException(ImageErrorCode errorCode) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberCommandService.java new file mode 100644 index 00000000..d7c315f4 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberCommandService.java @@ -0,0 +1,43 @@ +package com.mapbefine.mapbefine.member.application; + +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.dto.request.MemberUpdateRequest; +import com.mapbefine.mapbefine.member.exception.MemberErrorCode; +import com.mapbefine.mapbefine.member.exception.MemberException.MemberConflictException; +import java.util.NoSuchElementException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional +public class MemberCommandService { + + private final MemberRepository memberRepository; + + public MemberCommandService(MemberRepository memberRepository) { + this.memberRepository = memberRepository; + } + + public void updateInfoById(AuthMember authMember, MemberUpdateRequest request) { + Member member = findMemberById(authMember.getMemberId()); + String nickName = request.nickName(); + + validateNicknameDuplicated(nickName); + + member.update(nickName); + } + + private Member findMemberById(Long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new NoSuchElementException("findMemberById; memberId=" + memberId)); + } + + private void validateNicknameDuplicated(String nickName) { + if (memberRepository.existsByMemberInfoNickName(nickName)) { + throw new MemberConflictException(MemberErrorCode.ILLEGAL_NICKNAME_ALREADY_EXISTS, nickName); + } + } + +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberQueryService.java index c032cf36..df5296f9 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberQueryService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/application/MemberQueryService.java @@ -37,6 +37,7 @@ private Member findMemberById(Long id) { .orElseThrow(() -> new MemberNotFoundException(MemberErrorCode.MEMBER_NOT_FOUND, id)); } + // TODO: 2023/09/13 차단된 or 탈퇴한 사용자 필터링 필요 public List findAll() { return memberRepository.findAll() .stream() diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Member.java b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Member.java index 71de6abf..31ebf243 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Member.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Member.java @@ -64,13 +64,15 @@ public static Member of( String email, String imageUrl, Role role, + Status status, OauthId oauthId ) { MemberInfo memberInfo = MemberInfo.of( nickName, email, imageUrl, - role + role, + status ); return new Member(memberInfo, oauthId); @@ -85,7 +87,7 @@ public static Member ofRandomNickname( ) { String nickName = createNickname(nickname); - return Member.of(nickName, email, imageUrl, role, oauthId); + return Member.of(nickName, email, imageUrl, role, Status.NORMAL, oauthId); } private static String createNickname(String nickname) { @@ -98,21 +100,14 @@ private static String createNickname(String nickname) { private static String createNicknameSuffix() { return randomUUID() .toString() - .replaceAll("-", "") + .replace("-", "") .substring(0, DEFAULT_NICKNAME_SUFFIX_LENGTH); } public void update( - String nickName, - String email, - String imageUrl + String nickName ) { - memberInfo = MemberInfo.of( - nickName, - email, - imageUrl, - memberInfo.getRole() - ); + memberInfo = memberInfo.createUpdatedMemberInfo(nickName); } public void addTopic(Topic topic) { @@ -149,4 +144,17 @@ public List getTopicsWithPermissions() { .toList(); } + public boolean isNormalStatus() { + return memberInfo.getStatus() == Status.NORMAL; + } + + public void updateStatus(Status status) { + memberInfo = MemberInfo.of( + memberInfo.getNickName(), + memberInfo.getEmail(), + memberInfo.getImageUrl(), + memberInfo.getRole(), + status + ); + } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberInfo.java b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberInfo.java index c15054c3..ee36d80a 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberInfo.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberInfo.java @@ -28,7 +28,7 @@ public class MemberInfo { @Column(nullable = false, length = 20, unique = true) private String nickName; - @Column(nullable = false, unique = true) + @Column(nullable = false) private String email; @Column(nullable = false) @@ -38,33 +38,42 @@ public class MemberInfo { @Column(nullable = false) private Role role; + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private Status status; + private MemberInfo( String nickName, String email, Image imageUrl, - Role role + Role role, + Status status ) { this.nickName = nickName; this.email = email; this.imageUrl = imageUrl; this.role = role; + this.status = status; } public static MemberInfo of( String nickName, String email, String imageUrl, - Role role + Role role, + Status status ) { validateNickName(nickName); validateEmail(email); validateRole(role); + validateStatus(status); return new MemberInfo( nickName, email, Image.from(imageUrl), - role + role, + status ); } @@ -93,6 +102,17 @@ private static void validateRole(Role role) { } } + private static void validateStatus(Status status) { + if (Objects.isNull(status)) { + throw new IllegalArgumentException("validateStatus; member status is null;"); + } + } + + public MemberInfo createUpdatedMemberInfo(String nickName) { + + return MemberInfo.of(nickName, this.email, this.imageUrl.getImageUrl(), this.role, this.status); + } + public String getImageUrl() { return imageUrl.getImageUrl(); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberRepository.java index 16b57c05..05be31d2 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/MemberRepository.java @@ -1,16 +1,17 @@ package com.mapbefine.mapbefine.member.domain; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface MemberRepository extends JpaRepository { - Optional findByMemberInfoEmail(String email); + Optional findById(Long id); - boolean existsByMemberInfoEmail(String email); + Optional findByOauthId(OauthId oauthId); - Optional findByOauthIdOauthServerId(Long oauthServerId); + boolean existsByMemberInfoNickName(String nickName); - Optional findByOauthId(OauthId oauthId); + List findAllByMemberInfoRole(Role role); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Role.java b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Role.java index 9222f524..96b74983 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Role.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Role.java @@ -9,7 +9,7 @@ public enum Role { ADMIN("ROLE_ADMIN", "운영자"), - USER("ROLE_USER", "로그인 유저"), + USER("ROLE_USER", "로그인 회원"), GUEST("ROLE_GUEST", "손님"); private final String key; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Status.java b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Status.java new file mode 100644 index 00000000..bf594c09 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/domain/Status.java @@ -0,0 +1,15 @@ +package com.mapbefine.mapbefine.member.domain; + +public enum Status { + NORMAL("STATUS_NORMAL", "정상 사용자"), + DELETE("STATAUS_DELETE", "탈퇴한 사용자"), + BLOCKED("STATUS_BLOCKED", "차단된 사용자"); + + private final String key; + private final String title; + + Status(String key, String title) { + this.key = key; + this.title = title; + } +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/dto/request/MemberUpdateRequest.java b/backend/src/main/java/com/mapbefine/mapbefine/member/dto/request/MemberUpdateRequest.java new file mode 100644 index 00000000..bada64c9 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/dto/request/MemberUpdateRequest.java @@ -0,0 +1,6 @@ +package com.mapbefine.mapbefine.member.dto.request; + +public record MemberUpdateRequest( + String nickName +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberErrorCode.java b/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberErrorCode.java index c02d2ab1..98c42af4 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberErrorCode.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberErrorCode.java @@ -9,7 +9,9 @@ public enum MemberErrorCode { ILLEGAL_NICKNAME_LENGTH("05001", "닉네임 길이는 최소 1 자에서 20자여야 합니다."), ILLEGAL_EMAIL_NULL("05002", "이메일은 필수로 입력해야합니다."), ILLEGAL_EMAIL_PATTERN("05003", "올바르지 않은 이메일 형식입니다."), + FORBIDDEN_MEMBER_STATUS("05100", "탈퇴 혹은 차단된 회원입니다."), MEMBER_NOT_FOUND("05400", "존재하지 않는 회원입니다."), + ILLEGAL_NICKNAME_ALREADY_EXISTS("05900", "이미 존재하는 닉네임입니다."), ; private final String code; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberException.java b/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberException.java index ec98e9d0..b582c64c 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberException.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/exception/MemberException.java @@ -1,7 +1,9 @@ package com.mapbefine.mapbefine.member.exception; import com.mapbefine.mapbefine.common.exception.BadRequestException; +import com.mapbefine.mapbefine.common.exception.ConflictException; import com.mapbefine.mapbefine.common.exception.ErrorCode; +import com.mapbefine.mapbefine.common.exception.ForbiddenException; import com.mapbefine.mapbefine.common.exception.NotFoundException; public class MemberException { @@ -18,5 +20,17 @@ public MemberNotFoundException(MemberErrorCode errorCode, Long id) { } } + public static class MemberForbiddenException extends ForbiddenException { + public MemberForbiddenException(MemberErrorCode errorCode, Long id) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), id)); + } + } + + public static class MemberConflictException extends ConflictException { + public MemberConflictException(MemberErrorCode errorCode, String value) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), value)); + } + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/member/presentation/MemberController.java b/backend/src/main/java/com/mapbefine/mapbefine/member/presentation/MemberController.java index 119ab4fd..a9e63c5e 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/member/presentation/MemberController.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/member/presentation/MemberController.java @@ -2,7 +2,9 @@ import com.mapbefine.mapbefine.auth.domain.AuthMember; import com.mapbefine.mapbefine.common.interceptor.LoginRequired; +import com.mapbefine.mapbefine.member.application.MemberCommandService; import com.mapbefine.mapbefine.member.application.MemberQueryService; +import com.mapbefine.mapbefine.member.dto.request.MemberUpdateRequest; import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.member.dto.response.MemberResponse; import com.mapbefine.mapbefine.pin.dto.response.PinResponse; @@ -10,7 +12,9 @@ import java.util.List; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -18,9 +22,11 @@ @RequestMapping("/members") public class MemberController { + private final MemberCommandService memberCommandService; private final MemberQueryService memberQueryService; - public MemberController(MemberQueryService memberQueryService) { + public MemberController(MemberCommandService memberCommandService, MemberQueryService memberQueryService) { + this.memberCommandService = memberCommandService; this.memberQueryService = memberQueryService; } @@ -72,4 +78,12 @@ public ResponseEntity> findAllTopicsInBookmark(AuthMember au return ResponseEntity.ok(responses); } + @LoginRequired + @PatchMapping("/my/profiles") + public ResponseEntity updateMyInfo(AuthMember authMember, @RequestBody MemberUpdateRequest request) { + memberCommandService.updateInfoById(authMember, request); + + return ResponseEntity.ok().build(); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/application/OauthService.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/application/OauthService.java index d75526a0..e7edd8b7 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/application/OauthService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/oauth/application/OauthService.java @@ -1,31 +1,29 @@ package com.mapbefine.mapbefine.oauth.application; -import com.mapbefine.mapbefine.auth.infrastructure.JwtTokenProvider; +import com.mapbefine.mapbefine.auth.exception.AuthErrorCode; +import com.mapbefine.mapbefine.auth.exception.AuthException.AuthUnauthorizedException; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.oauth.domain.AuthCodeRequestUrlProviderComposite; import com.mapbefine.mapbefine.oauth.domain.OauthMember; import com.mapbefine.mapbefine.oauth.domain.OauthMemberClientComposite; import com.mapbefine.mapbefine.oauth.domain.OauthServerType; -import com.mapbefine.mapbefine.oauth.dto.LoginInfoResponse; import org.springframework.stereotype.Service; @Service public class OauthService { - private MemberRepository memberRepository; - private JwtTokenProvider jwtTokenProvider; - private AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; - private OauthMemberClientComposite oauthMemberClientComposite; + private final MemberRepository memberRepository; + private final AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite; + private final OauthMemberClientComposite oauthMemberClientComposite; public OauthService( MemberRepository memberRepository, - JwtTokenProvider jwtTokenProvider, AuthCodeRequestUrlProviderComposite authCodeRequestUrlProviderComposite, OauthMemberClientComposite oauthMemberClientComposite ) { this.memberRepository = memberRepository; - this.jwtTokenProvider = jwtTokenProvider; this.authCodeRequestUrlProviderComposite = authCodeRequestUrlProviderComposite; this.oauthMemberClientComposite = oauthMemberClientComposite; } @@ -34,14 +32,14 @@ public String getAuthCodeRequestUrl(OauthServerType oauthServerType) { return authCodeRequestUrlProviderComposite.provide(oauthServerType); } - public LoginInfoResponse login(OauthServerType oauthServerType, String code) { + public MemberDetailResponse login(OauthServerType oauthServerType, String code) { OauthMember oauthMember = oauthMemberClientComposite.fetch(oauthServerType, code); Member savedMember = memberRepository.findByOauthId(oauthMember.getOauthId()) .orElseGet(() -> register(oauthMember)); - String accessToken = jwtTokenProvider.createToken(String.valueOf(savedMember.getId())); + validateMemberStatus(savedMember); - return LoginInfoResponse.of(accessToken, savedMember); + return MemberDetailResponse.from(savedMember); } private Member register(OauthMember oauthMember) { @@ -49,4 +47,11 @@ private Member register(OauthMember oauthMember) { return memberRepository.save(oauthMember.toRegisterMember()); } + private void validateMemberStatus(Member member) { + if (member.isNormalStatus()) { + return; + } + throw new AuthUnauthorizedException(AuthErrorCode.BLOCKING_MEMBER_ACCESS); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/AuthCodeRequestUrlProviderComposite.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/AuthCodeRequestUrlProviderComposite.java index a2a3c518..2ad2295d 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/AuthCodeRequestUrlProviderComposite.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/AuthCodeRequestUrlProviderComposite.java @@ -4,7 +4,7 @@ import static java.util.function.UnaryOperator.identity; import static java.util.stream.Collectors.toMap; -import com.mapbefine.mapbefine.oauth.exception.OathException.OauthNotFoundException; +import com.mapbefine.mapbefine.oauth.exception.OauthException.OauthNotFoundException; import java.util.Map; import java.util.Optional; import java.util.Set; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberClientComposite.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberClientComposite.java index f5fb9648..eba1172d 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberClientComposite.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberClientComposite.java @@ -4,7 +4,7 @@ import static java.util.function.UnaryOperator.identity; import static java.util.stream.Collectors.toMap; -import com.mapbefine.mapbefine.oauth.exception.OathException.OauthNotFoundException; +import com.mapbefine.mapbefine.oauth.exception.OauthException.OauthNotFoundException; import java.util.Map; import java.util.Optional; import java.util.Set; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/kakao/KakaoAuthCodeRequestUrlProvider.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/kakao/KakaoAuthCodeRequestUrlProvider.java index a62ab39b..eb2054ce 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/kakao/KakaoAuthCodeRequestUrlProvider.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/oauth/domain/kakao/KakaoAuthCodeRequestUrlProvider.java @@ -2,16 +2,20 @@ import com.mapbefine.mapbefine.oauth.domain.AuthCodeRequestUrlProvider; import com.mapbefine.mapbefine.oauth.domain.OauthServerType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Component; import org.springframework.web.util.UriComponentsBuilder; @Component public class KakaoAuthCodeRequestUrlProvider implements AuthCodeRequestUrlProvider { + private final Logger log = LoggerFactory.getLogger(KakaoAuthCodeRequestUrlProvider.class); private final KakaoOauthProperties kakaoOauthProperties; public KakaoAuthCodeRequestUrlProvider(KakaoOauthProperties kakaoOauthProperties) { this.kakaoOauthProperties = kakaoOauthProperties; + log.debug("client_id: {}", kakaoOauthProperties.redirectUri()); } @Override diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/dto/LoginInfoResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/dto/LoginInfoResponse.java deleted file mode 100644 index bdf52394..00000000 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/dto/LoginInfoResponse.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.mapbefine.mapbefine.oauth.dto; - -import com.mapbefine.mapbefine.member.domain.Member; -import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; - -public record LoginInfoResponse( - String accessToken, - MemberDetailResponse member -) { - - public static LoginInfoResponse of(String accessToken, Member member) { - return new LoginInfoResponse(accessToken, MemberDetailResponse.from(member)); - } - -} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OathException.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OauthException.java similarity index 94% rename from backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OathException.java rename to backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OauthException.java index a18d234c..3dc67e35 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OathException.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/oauth/exception/OauthException.java @@ -4,7 +4,7 @@ import com.mapbefine.mapbefine.common.exception.NotFoundException; import com.mapbefine.mapbefine.oauth.domain.OauthServerType; -public class OathException { +public class OauthException { public static class OauthNotFoundException extends NotFoundException { public OauthNotFoundException(OauthErrorCode errorCode, OauthServerType oauthServerType) { diff --git a/backend/src/main/java/com/mapbefine/mapbefine/oauth/presentation/OauthController.java b/backend/src/main/java/com/mapbefine/mapbefine/oauth/presentation/OauthController.java deleted file mode 100644 index 410697c9..00000000 --- a/backend/src/main/java/com/mapbefine/mapbefine/oauth/presentation/OauthController.java +++ /dev/null @@ -1,46 +0,0 @@ -package com.mapbefine.mapbefine.oauth.presentation; - -import com.mapbefine.mapbefine.oauth.application.OauthService; -import com.mapbefine.mapbefine.oauth.domain.OauthServerType; -import com.mapbefine.mapbefine.oauth.dto.LoginInfoResponse; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/oauth") -public class OauthController { - - private final OauthService oauthService; - - public OauthController(final OauthService oauthService) { - this.oauthService = oauthService; - } - - @GetMapping("/{oauthServerType}") - public ResponseEntity redirection( - @PathVariable OauthServerType oauthServerType, - HttpServletResponse response - ) throws IOException { - String redirectUrl = oauthService.getAuthCodeRequestUrl(oauthServerType); - response.sendRedirect(redirectUrl); - - return ResponseEntity.ok().build(); - } - - @GetMapping("/login/{oauthServerType}") - public ResponseEntity login( - @PathVariable OauthServerType oauthServerType, - @RequestParam String code - ) { - LoginInfoResponse loginInfo = oauthService.login(oauthServerType, code); - - return ResponseEntity.ok(loginInfo); - } - -} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/application/PermissionQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/application/PermissionQueryService.java index 7765d151..5b4bb4ab 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/application/PermissionQueryService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/application/PermissionQueryService.java @@ -4,9 +4,16 @@ import com.mapbefine.mapbefine.permission.domain.Permission; import com.mapbefine.mapbefine.permission.domain.PermissionRepository; -import com.mapbefine.mapbefine.permission.dto.response.PermissionDetailResponse; -import com.mapbefine.mapbefine.permission.dto.response.PermissionResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionMemberDetailResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionedMemberResponse; +import com.mapbefine.mapbefine.permission.dto.response.TopicAccessDetailResponse; import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionNotFoundException; +import com.mapbefine.mapbefine.topic.domain.Publicity; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import com.mapbefine.mapbefine.topic.domain.TopicStatus; +import com.mapbefine.mapbefine.topic.exception.TopicErrorCode; +import com.mapbefine.mapbefine.topic.exception.TopicException.TopicNotFoundException; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -16,22 +23,35 @@ public class PermissionQueryService { private final PermissionRepository permissionRepository; + private final TopicRepository topicRepository; - public PermissionQueryService(PermissionRepository permissionRepository) { + public PermissionQueryService(PermissionRepository permissionRepository, TopicRepository topicRepository) { this.permissionRepository = permissionRepository; + this.topicRepository = topicRepository; } - public List findAllTopicPermissions(Long topicId) { - return permissionRepository.findAllByTopicId(topicId) + public TopicAccessDetailResponse findTopicAccessDetailById(Long topicId) { + Publicity publicity = findTopicPublicityById(topicId); + /// TODO: 2023/09/15 이럴거면 topic.getPermissions 로 하는 게 나을 수도 있나? TopicController 에서 하는 게 더 자연스러운 것 같기도.. + List permissionedMembers = permissionRepository.findAllByTopicId(topicId) .stream() - .map(PermissionResponse::from) + .map(PermissionedMemberResponse::from) .toList(); + + return new TopicAccessDetailResponse(publicity, permissionedMembers); + } + + private Publicity findTopicPublicityById(Long topicId) { + return topicRepository.findById(topicId) + .map(Topic::getTopicStatus) + .map(TopicStatus::getPublicity) + .orElseThrow(() -> new TopicNotFoundException(TopicErrorCode.TOPIC_NOT_FOUND, topicId)); } - public PermissionDetailResponse findPermissionById(Long permissionId) { + public PermissionMemberDetailResponse findPermissionById(Long permissionId) { Permission permission = permissionRepository.findById(permissionId) .orElseThrow(() -> new PermissionNotFoundException(PERMISSION_NOT_FOUND, permissionId)); - return PermissionDetailResponse.from(permission); + return PermissionMemberDetailResponse.from(permission); } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/Permission.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/Permission.java index e58678b1..8f6bf1ca 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/Permission.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/Permission.java @@ -19,6 +19,7 @@ @Getter public class Permission extends BaseTimeEntity { + // TODO 매핑 테이블인데 Id를 가져야 할까? @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/PermissionRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/PermissionRepository.java index da8dca9b..7d14c061 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/PermissionRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/domain/PermissionRepository.java @@ -9,4 +9,5 @@ public interface PermissionRepository extends JpaRepository { boolean existsByTopicIdAndMemberId(Long topicId, Long memberId); + void deleteAllByMemberId(Long memberId); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionDetailResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionMemberDetailResponse.java similarity index 73% rename from backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionDetailResponse.java rename to backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionMemberDetailResponse.java index 967fa8eb..d42946de 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionDetailResponse.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionMemberDetailResponse.java @@ -4,14 +4,14 @@ import com.mapbefine.mapbefine.permission.domain.Permission; import java.time.LocalDateTime; -public record PermissionDetailResponse( +public record PermissionMemberDetailResponse( Long id, LocalDateTime updatedAt, MemberDetailResponse memberDetailResponse ) { - public static PermissionDetailResponse from(Permission permission) { - return new PermissionDetailResponse( + public static PermissionMemberDetailResponse from(Permission permission) { + return new PermissionMemberDetailResponse( permission.getId(), permission.getUpdatedAt(), MemberDetailResponse.from(permission.getMember()) diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionedMemberResponse.java similarity index 68% rename from backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionResponse.java rename to backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionedMemberResponse.java index 9c4f2368..dda91d86 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionResponse.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/PermissionedMemberResponse.java @@ -3,13 +3,13 @@ import com.mapbefine.mapbefine.member.dto.response.MemberResponse; import com.mapbefine.mapbefine.permission.domain.Permission; -public record PermissionResponse( +public record PermissionedMemberResponse( Long id, MemberResponse memberResponse ) { - public static PermissionResponse from(Permission permission) { - return new PermissionResponse( + public static PermissionedMemberResponse from(Permission permission) { + return new PermissionedMemberResponse( permission.getId(), MemberResponse.from(permission.getMember()) ); diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/TopicAccessDetailResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/TopicAccessDetailResponse.java new file mode 100644 index 00000000..7d296566 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/dto/response/TopicAccessDetailResponse.java @@ -0,0 +1,10 @@ +package com.mapbefine.mapbefine.permission.dto.response; + +import com.mapbefine.mapbefine.topic.domain.Publicity; +import java.util.List; + +public record TopicAccessDetailResponse( + Publicity publicity, + List permissionedMembers +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/exception/PermissionErrorCode.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/exception/PermissionErrorCode.java index 5a0f6aa8..c3f3f699 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/exception/PermissionErrorCode.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/exception/PermissionErrorCode.java @@ -9,7 +9,8 @@ public enum PermissionErrorCode { ILLEGAL_PERMISSION_ID("07001", "유효하지 않은 권한 정보입니다."), FORBIDDEN_ADD_PERMISSION_GUEST("07300", "로그인하지 않은 사용자는 권한을 줄 수 없습니다."), FORBIDDEN_ADD_PERMISSION("07301", "지도를 생성한 사용자가 아니면 권한을 줄 수 없습니다."), - PERMISSION_NOT_FOUND("07400", "존재하지 않는 권한 정보입니다.") + PERMISSION_NOT_FOUND("07400", "존재하지 않는 권한 정보입니다."), + PERMISSION_FORBIDDEN_BY_NOT_ADMIN("07401", "어드민 계정만 접근 가능합니다."), ; private final String code; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/permission/presentation/PermissionController.java b/backend/src/main/java/com/mapbefine/mapbefine/permission/presentation/PermissionController.java index 750b9bc4..188348ea 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/permission/presentation/PermissionController.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/permission/presentation/PermissionController.java @@ -5,9 +5,8 @@ import com.mapbefine.mapbefine.permission.application.PermissionCommandService; import com.mapbefine.mapbefine.permission.application.PermissionQueryService; import com.mapbefine.mapbefine.permission.dto.request.PermissionRequest; -import com.mapbefine.mapbefine.permission.dto.response.PermissionDetailResponse; -import com.mapbefine.mapbefine.permission.dto.response.PermissionResponse; -import java.util.List; +import com.mapbefine.mapbefine.permission.dto.response.PermissionMemberDetailResponse; +import com.mapbefine.mapbefine.permission.dto.response.TopicAccessDetailResponse; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -51,16 +50,18 @@ public ResponseEntity deleteMemberTopicPermission(AuthMember authMember, @ @LoginRequired @GetMapping("/topics/{topicId}") - public ResponseEntity> findAllTopicPermissions(@PathVariable Long topicId) { - List responses = permissionQueryService.findAllTopicPermissions(topicId); + public ResponseEntity findTopicAccessDetailByTopicId(@PathVariable Long topicId) { + TopicAccessDetailResponse response = permissionQueryService.findTopicAccessDetailById(topicId); - return ResponseEntity.ok(responses); + return ResponseEntity.ok(response); } + // TODO 이 API를 쓰는 곳이 있나? + 결국 특정 회원을 조회하는 건데 어떤 API인지 알기 어렵다.. + // 회원 정보 조회는 /members 에서 하는 걸로 충분하지 않나? 재사용성이 떨어진다. 테스트의 DisplayName도 매칭이 안된다. @LoginRequired @GetMapping("/{permissionId}") - public ResponseEntity findPermissionById(@PathVariable Long permissionId) { - PermissionDetailResponse response = permissionQueryService.findPermissionById(permissionId); + public ResponseEntity findPermissionById(@PathVariable Long permissionId) { + PermissionMemberDetailResponse response = permissionQueryService.findPermissionById(permissionId); return ResponseEntity.ok(response); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinCommandService.java index 80f0cd35..b1f5ada8 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinCommandService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinCommandService.java @@ -3,6 +3,7 @@ import static com.mapbefine.mapbefine.pin.exception.PinErrorCode.FORBIDDEN_PIN_CREATE_OR_UPDATE; import static com.mapbefine.mapbefine.pin.exception.PinErrorCode.ILLEGAL_PIN_ID; import static com.mapbefine.mapbefine.pin.exception.PinErrorCode.ILLEGAL_PIN_IMAGE_ID; +import static com.mapbefine.mapbefine.image.exception.ImageErrorCode.IMAGE_FILE_IS_NULL; import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_TOPIC_ID; import com.mapbefine.mapbefine.auth.domain.AuthMember; @@ -21,13 +22,17 @@ import com.mapbefine.mapbefine.pin.dto.request.PinUpdateRequest; import com.mapbefine.mapbefine.pin.exception.PinException.PinBadRequestException; import com.mapbefine.mapbefine.pin.exception.PinException.PinForbiddenException; +import com.mapbefine.mapbefine.image.application.ImageService; +import com.mapbefine.mapbefine.image.exception.ImageException.ImageBadRequestException; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; import com.mapbefine.mapbefine.topic.exception.TopicException.TopicBadRequestException; +import java.util.List; import java.util.NoSuchElementException; import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Transactional @Service @@ -40,23 +45,29 @@ public class PinCommandService { private final TopicRepository topicRepository; private final MemberRepository memberRepository; private final PinImageRepository pinImageRepository; + private final ImageService imageService; public PinCommandService( PinRepository pinRepository, LocationRepository locationRepository, TopicRepository topicRepository, MemberRepository memberRepository, - PinImageRepository pinImageRepository + PinImageRepository pinImageRepository, + ImageService imageService ) { this.pinRepository = pinRepository; this.locationRepository = locationRepository; this.topicRepository = topicRepository; this.memberRepository = memberRepository; this.pinImageRepository = pinImageRepository; + this.imageService = imageService; } - - public long save(AuthMember authMember, PinCreateRequest request) { + public long save( + AuthMember authMember, + List images, + PinCreateRequest request + ) { Topic topic = findTopic(request.topicId()); validatePinCreateOrUpdate(authMember, topic); @@ -68,11 +79,22 @@ public long save(AuthMember authMember, PinCreateRequest request) { topic, member ); + + addPinImagesToPin(images, pin); + pinRepository.save(pin); return pin.getId(); } + private void addPinImagesToPin(final List images, final Pin pin) { + if (Objects.isNull(images)) { + return; + } + + images.forEach(image -> addImageToPin(image, pin)); + } + private Topic findTopic(Long topicId) { if (Objects.isNull(topicId)) { throw new TopicBadRequestException(ILLEGAL_TOPIC_ID); @@ -138,9 +160,16 @@ public void removeById(AuthMember authMember, Long pinId) { public void addImage(AuthMember authMember, PinImageCreateRequest request) { Pin pin = findPin(request.pinId()); validatePinCreateOrUpdate(authMember, pin.getTopic()); + addImageToPin(request.image(), pin); + } + + private void addImageToPin(MultipartFile image, Pin pin) { + if (Objects.isNull(image)) { + throw new ImageBadRequestException(IMAGE_FILE_IS_NULL); + } - PinImage pinImage = PinImage.createPinImageAssociatedWithPin(request.imageUrl(), pin); - pinImageRepository.save(pinImage); + String imageUrl = imageService.upload(image); + PinImage.createPinImageAssociatedWithPin(imageUrl, pin); } public void removeImageById(AuthMember authMember, Long pinImageId) { diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinQueryService.java index f5a0b23a..0e2aa2bf 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinQueryService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/application/PinQueryService.java @@ -40,7 +40,7 @@ public PinDetailResponse findDetailById(AuthMember member, Long pinId) { .orElseThrow(() -> new PinNotFoundException(PIN_NOT_FOUND, pinId)); validateReadAuth(member, pin.getTopic()); - return PinDetailResponse.from(pin); + return PinDetailResponse.of(pin, member.canPinCreateOrUpdate(pin.getTopic())); } private void validateReadAuth(AuthMember member, Topic topic) { @@ -52,7 +52,7 @@ private void validateReadAuth(AuthMember member, Topic topic) { } public List findAllPinsByMemberId(AuthMember authMember, Long memberId) { - return pinRepository.findByCreatorId(memberId) + return pinRepository.findAllByCreatorId(memberId) .stream() .filter(pin -> authMember.canRead(pin.getTopic())) .map(PinResponse::from) diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java index 194bcaf1..8081b913 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/Pin.java @@ -17,6 +17,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; import java.util.ArrayList; import java.util.List; import lombok.Getter; @@ -86,6 +88,16 @@ public static Pin createPinAssociatedWithLocationAndTopicAndMember( return pin; } + @PrePersist + protected void prePersist() { + topic.updateLastPinUpdatedAt(getUpdatedAt()); + } + + @PreUpdate + protected void preUpdate() { + topic.updateLastPinUpdatedAt(getUpdatedAt()); + } + public void updatePinInfo(String name, String description) { pinInfo = PinInfo.of(name, description); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinImageRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinImageRepository.java index 36f53800..6e25d0af 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinImageRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinImageRepository.java @@ -18,6 +18,9 @@ public interface PinImageRepository extends JpaRepository { @Query("update PinImage p set p.isDeleted = true where p.id = :id") void deleteById(@Param("id") Long id); + @Modifying(clearAutomatically = true) + @Query("update PinImage p set p.isDeleted = true where p.pin.id in :pinIds") + void deleteAllByPinIds(@Param("pinIds") List pinIds); + List findAllByPinId(Long pinId); - } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java index 11a2d95d..20ac5a4a 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/domain/PinRepository.java @@ -18,9 +18,12 @@ public interface PinRepository extends JpaRepository { @Query("update Pin p set p.isDeleted = true where p.id = :pinId") void deleteById(@Param("pinId") Long pinId); + @Modifying(clearAutomatically = true) + @Query("update Pin p set p.isDeleted = true where p.creator.id = :memberId") + void deleteAllByMemberId(@Param("memberId") Long memberId); + List findAllByTopicId(Long topicId); - List findByCreatorId(Long creatorId); + List findAllByCreatorId(Long creatorId); - List findAllByOrderByUpdatedAtDesc(); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/request/PinImageCreateRequest.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/request/PinImageCreateRequest.java index d43e74ee..4ad5ef73 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/request/PinImageCreateRequest.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/request/PinImageCreateRequest.java @@ -1,7 +1,9 @@ package com.mapbefine.mapbefine.pin.dto.request; +import org.springframework.web.multipart.MultipartFile; + public record PinImageCreateRequest( Long pinId, - String imageUrl + MultipartFile image ) { } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/response/PinDetailResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/response/PinDetailResponse.java index c1f241fc..33c3cea7 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/response/PinDetailResponse.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/dto/response/PinDetailResponse.java @@ -13,11 +13,12 @@ public record PinDetailResponse( String creator, double latitude, double longitude, + Boolean canUpdate, LocalDateTime updatedAt, List images ) { - public static PinDetailResponse from(Pin pin) { + public static PinDetailResponse of(Pin pin, Boolean canUpdate) { PinInfo pinInfo = pin.getPinInfo(); return new PinDetailResponse( @@ -28,6 +29,7 @@ public static PinDetailResponse from(Pin pin) { pin.getCreator().getMemberInfo().getNickName(), pin.getLatitude(), pin.getLongitude(), + canUpdate, pin.getUpdatedAt(), PinImageResponse.from(pin.getPinImages()) ); diff --git a/backend/src/main/java/com/mapbefine/mapbefine/pin/presentation/PinController.java b/backend/src/main/java/com/mapbefine/mapbefine/pin/presentation/PinController.java index b85d6cb9..d144d889 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/pin/presentation/PinController.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/pin/presentation/PinController.java @@ -12,6 +12,7 @@ import java.net.URI; import java.util.List; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -21,7 +22,9 @@ 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.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/pins") @@ -36,9 +39,13 @@ public PinController(PinCommandService pinCommandService, PinQueryService pinQue } @LoginRequired - @PostMapping - public ResponseEntity add(AuthMember member, @RequestBody PinCreateRequest request) { - long savedId = pinCommandService.save(member, request); + @PostMapping(consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE}) + public ResponseEntity add( + AuthMember member, + @RequestPart(required = false) List images, + @RequestPart PinCreateRequest request + ) { + long savedId = pinCommandService.save(member, images, request); return ResponseEntity.created(URI.create("/pins/" + savedId)) .build(); @@ -92,9 +99,16 @@ public ResponseEntity> findAllPinsByMemberId( } @LoginRequired - @PostMapping("/images") - public ResponseEntity addImage(AuthMember member, @RequestBody PinImageCreateRequest request) { - pinCommandService.addImage(member, request); + @PostMapping( + value = "/images", + consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE} + ) + public ResponseEntity addImage( + AuthMember member, + @RequestPart Long pinId, + @RequestPart(required = false) MultipartFile image + ) { + pinCommandService.addImage(member, new PinImageCreateRequest(pinId, image)); return ResponseEntity.status(HttpStatus.CREATED).build(); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java index 77b77643..30a7ba89 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicCommandService.java @@ -14,6 +14,7 @@ import com.mapbefine.mapbefine.pin.domain.PinRepository; import com.mapbefine.mapbefine.pin.exception.PinException.PinBadRequestException; import com.mapbefine.mapbefine.pin.exception.PinException.PinForbiddenException; +import com.mapbefine.mapbefine.image.application.ImageService; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest; @@ -27,6 +28,7 @@ import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Transactional @Service @@ -35,22 +37,25 @@ public class TopicCommandService { private final TopicRepository topicRepository; private final PinRepository pinRepository; private final MemberRepository memberRepository; + private final ImageService imageService; public TopicCommandService( TopicRepository topicRepository, PinRepository pinRepository, - MemberRepository memberRepository + MemberRepository memberRepository, + ImageService imageService ) { this.topicRepository = topicRepository; this.pinRepository = pinRepository; this.memberRepository = memberRepository; + this.imageService = imageService; } public Long saveTopic(AuthMember member, TopicCreateRequest request) { Topic topic = convertToTopic(member, request); List pinIds = request.pins(); - if (pinIds.size() > 0) { + if (0 < pinIds.size()) { copyPinsToTopic(member, topic, pinIds); } @@ -61,17 +66,26 @@ public Long saveTopic(AuthMember member, TopicCreateRequest request) { private Topic convertToTopic(AuthMember member, TopicCreateRequest request) { Member creator = findCreatorByAuthMember(member); + String image = createImageUrl(request.image()); return Topic.createTopicAssociatedWithCreator( request.name(), request.description(), - request.image(), + image, request.publicity(), request.permissionType(), creator ); } + private String createImageUrl(MultipartFile image) { + if (Objects.isNull(image)) { + return null; + } + + return imageService.upload(image); + } + private Member findCreatorByAuthMember(AuthMember member) { Long memberId = member.getMemberId(); if (Objects.isNull(memberId)) { @@ -132,11 +146,12 @@ public Long merge(AuthMember member, TopicMergeRequest request) { private Topic convertToTopic(AuthMember member, TopicMergeRequest request) { Member creator = findCreatorByAuthMember(member); + String imageUrl = createImageUrl(request.image()); return Topic.createTopicAssociatedWithCreator( request.name(), request.description(), - request.image(), + imageUrl, request.publicity(), request.permissionType(), creator diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java index 23cd0ae8..5f217e71 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/application/TopicQueryService.java @@ -8,8 +8,6 @@ import com.mapbefine.mapbefine.bookmark.domain.Bookmark; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; -import com.mapbefine.mapbefine.pin.domain.Pin; -import com.mapbefine.mapbefine.pin.domain.PinRepository; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; @@ -28,16 +26,13 @@ public class TopicQueryService { private final TopicRepository topicRepository; - private final PinRepository pinRepository; private final MemberRepository memberRepository; public TopicQueryService( TopicRepository topicRepository, - PinRepository pinRepository, MemberRepository memberRepository ) { this.topicRepository = topicRepository; - this.pinRepository = pinRepository; this.memberRepository = memberRepository; } @@ -52,7 +47,7 @@ private List getGuestTopicResponses(AuthMember authMember) { return topicRepository.findAll() .stream() .filter(authMember::canRead) - .map(topic -> TopicResponse.from(topic, Boolean.FALSE, Boolean.FALSE)) + .map(TopicResponse::fromGuestQuery) .toList(); } @@ -99,13 +94,12 @@ private boolean isBookMarked(List bookMarkedTopics, Topic topic) { return bookMarkedTopics.contains(topic); } - public TopicDetailResponse findDetailById(AuthMember authMember, Long topicId) { Topic topic = findTopic(topicId); validateReadableTopic(authMember, topic); if (Objects.isNull(authMember.getMemberId())) { - return TopicDetailResponse.from(topic, Boolean.FALSE, Boolean.FALSE); + return TopicDetailResponse.fromGuestQuery(topic); } Member member = findMemberById(authMember.getMemberId()); @@ -113,10 +107,11 @@ public TopicDetailResponse findDetailById(AuthMember authMember, Long topicId) { List topicsInAtlas = findTopicsInAtlas(member); List topicsInBookMark = findBookMarkedTopics(member); - return TopicDetailResponse.from( + return TopicDetailResponse.of( topic, isInAtlas(topicsInAtlas, topic), - isBookMarked(topicsInBookMark, topic) + isBookMarked(topicsInBookMark, topic), + authMember.canTopicUpdate(topic) ); } @@ -140,18 +135,12 @@ public List findDetailsByIds(AuthMember authMember, List getGuestTopicDetailResponses(List topics) { - return topics.stream() - .map(topic -> TopicDetailResponse.from(topic, Boolean.FALSE, Boolean.FALSE)) - .toList(); - } - private List getUserTopicDetailResponses(AuthMember authMember, List topics) { Member member = findMemberById(authMember.getMemberId()); @@ -159,10 +148,11 @@ private List getUserTopicDetailResponses(AuthMember authMem List topicsInBookMark = findBookMarkedTopics(member); return topics.stream() - .map(topic -> TopicDetailResponse.from( + .map(topic -> TopicDetailResponse.of( topic, isInAtlas(topicsInAtlas, topic), - isBookMarked(topicsInBookMark, topic) + isBookMarked(topicsInBookMark, topic), + authMember.canTopicUpdate(topic) )) .toList(); } @@ -191,10 +181,10 @@ private void validateReadableTopics(AuthMember member, List topics) { public List findAllTopicsByMemberId(AuthMember authMember, Long memberId) { if (Objects.isNull(authMember.getMemberId())) { - return topicRepository.findByCreatorId(memberId) + return topicRepository.findAllByCreatorId(memberId) .stream() .filter(authMember::canRead) - .map(topic -> TopicResponse.from(topic, Boolean.FALSE, Boolean.FALSE)) + .map(TopicResponse::fromGuestQuery) .toList(); } @@ -203,7 +193,7 @@ public List findAllTopicsByMemberId(AuthMember authMember, Long m List topicsInAtlas = findTopicsInAtlas(member); List topicsInBookMark = findBookMarkedTopics(member); - return topicRepository.findByCreatorId(memberId) + return topicRepository.findAllByCreatorId(memberId) .stream() .filter(authMember::canRead) .map(topic -> TopicResponse.from( @@ -228,10 +218,8 @@ private List getUserNewestTopicResponse(AuthMember authMember) { List topicsInAtlas = findTopicsInAtlas(member); List topicsInBookMark = findBookMarkedTopics(member); - return pinRepository.findAllByOrderByUpdatedAtDesc() + return topicRepository.findAllByOrderByLastPinUpdatedAtDesc() .stream() - .map(Pin::getTopic) - .distinct() .filter(authMember::canRead) .map(topic -> TopicResponse.from( topic, @@ -242,16 +230,11 @@ private List getUserNewestTopicResponse(AuthMember authMember) { } private List getGuestNewestTopicResponse(AuthMember authMember) { - return pinRepository.findAllByOrderByUpdatedAtDesc() + return topicRepository.findAllByOrderByLastPinUpdatedAtDesc() .stream() - .map(Pin::getTopic) - .distinct() .filter(authMember::canRead) - .map(topic -> TopicResponse.from( - topic, - Boolean.FALSE, - Boolean.FALSE - )).toList(); + .map(TopicResponse::fromGuestQuery) + .toList(); } public List findAllBestTopics(AuthMember authMember) { @@ -266,11 +249,8 @@ private List getGuestBestTopicResponse(AuthMember authMember) { .stream() .filter(authMember::canRead) .sorted(Comparator.comparing(Topic::countBookmarks).reversed()) - .map(topic -> TopicResponse.from( - topic, - Boolean.FALSE, - Boolean.FALSE - )).toList(); + .map(TopicResponse::fromGuestQuery) + .toList(); } private List getUserBestTopicResponse(AuthMember authMember) { diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java index a41c807e..3483e21c 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/Topic.java @@ -17,6 +17,8 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.PrePersist; +import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import lombok.Getter; @@ -55,6 +57,9 @@ public class Topic extends BaseTimeEntity { @ColumnDefault(value = "false") private boolean isDeleted = false; + @Column(nullable = false) + private LocalDateTime lastPinUpdatedAt; + private Topic( TopicInfo topicInfo, TopicStatus topicStatus, @@ -65,6 +70,11 @@ private Topic( this.creator = creator; } + @PrePersist + protected void prePersist() { + lastPinUpdatedAt = LocalDateTime.now(); + } + public static Topic createTopicAssociatedWithCreator( String name, String description, @@ -90,6 +100,10 @@ public void updateTopicInfo( this.topicInfo = TopicInfo.of(name, description, imageUrl); } + public void updateLastPinUpdatedAt(LocalDateTime lastPinUpdatedAt) { + this.lastPinUpdatedAt = lastPinUpdatedAt; + } + public void updateTopicStatus(Publicity publicity, PermissionType permissionType) { topicStatus.update(publicity, permissionType); } @@ -114,4 +128,11 @@ public int countBookmarks() { return bookmarks.size(); } + public Publicity getPublicity() { + return topicStatus.getPublicity(); + } + public void removeImage() { + this.topicInfo = topicInfo.removeImage(); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicInfo.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicInfo.java index 3f5ffd59..f7dfe464 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicInfo.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicInfo.java @@ -21,8 +21,9 @@ @Getter public class TopicInfo { - private static final Image DEFAULT_IMAGE = - Image.from("https://map-befine-official.github.io/favicon.png"); + private static final Image DEFAULT_IMAGE = Image.from( + "https://velog.velcdn.com/images/semnil5202/post/37f3bcb9-0b07-4100-85f6-f1d5ad037c14/image.svg" + ); private static final int MAX_DESCRIPTION_LENGTH = 1000; private static final int MAX_NAME_LENGTH = 20; @@ -88,4 +89,13 @@ private static Image createImage(String imageUrl) { public String getImageUrl() { return image.getImageUrl(); } + + public TopicInfo removeImage() { + return new TopicInfo( + this.name, + this.description, + DEFAULT_IMAGE + ); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicRepository.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicRepository.java index 1ef9fb6f..22fe220a 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicRepository.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicRepository.java @@ -12,12 +12,18 @@ public interface TopicRepository extends JpaRepository { List findByIdIn(List ids); + boolean existsById(Long id); + + List findAllByOrderByLastPinUpdatedAtDesc(); + + List findAllByCreatorId(Long creatorId); + @Modifying(clearAutomatically = true) @Query("update Topic t set t.isDeleted = true where t.id = :topicId") void deleteById(@Param("topicId") Long topicId); - boolean existsById(Long id); - - List findByCreatorId(Long creatorId); + @Modifying(clearAutomatically = true) + @Query("update Topic t set t.isDeleted = true where t.creator.id = :memberId") + void deleteAllByMemberId(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicStatus.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicStatus.java index 987a19a5..88a79387 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicStatus.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/domain/TopicStatus.java @@ -1,7 +1,7 @@ package com.mapbefine.mapbefine.topic.domain; -import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PERMISSION_NULL; import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PERMISSION_FOR_PUBLICITY_PRIVATE; +import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PERMISSION_NULL; import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PERMISSION_UPDATE; import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PUBLICITY_FOR_PERMISSION_ALL_MEMBERS; import static com.mapbefine.mapbefine.topic.exception.TopicErrorCode.ILLEGAL_PUBLICITY_NULL; diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequest.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequest.java index e7dc64c5..40efd476 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequest.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequest.java @@ -3,13 +3,29 @@ import com.mapbefine.mapbefine.topic.domain.PermissionType; import com.mapbefine.mapbefine.topic.domain.Publicity; import java.util.List; +import org.springframework.web.multipart.MultipartFile; public record TopicCreateRequest( String name, - String image, + MultipartFile image, String description, Publicity publicity, PermissionType permissionType, List pins ) { + + public static TopicCreateRequest of( + TopicCreateRequestWithoutImage request, + MultipartFile image + ) { + return new TopicCreateRequest( + request.name(), + image, + request.description(), + request.publicity(), + request.permissionType(), + request.pins() + ); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequestWithoutImage.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequestWithoutImage.java new file mode 100644 index 00000000..5cfeb905 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicCreateRequestWithoutImage.java @@ -0,0 +1,14 @@ +package com.mapbefine.mapbefine.topic.dto.request; + +import com.mapbefine.mapbefine.topic.domain.PermissionType; +import com.mapbefine.mapbefine.topic.domain.Publicity; +import java.util.List; + +public record TopicCreateRequestWithoutImage( + String name, + String description, + Publicity publicity, + PermissionType permissionType, + List pins +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequest.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequest.java index deb14c79..920bbe2a 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequest.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequest.java @@ -3,13 +3,29 @@ import com.mapbefine.mapbefine.topic.domain.PermissionType; import com.mapbefine.mapbefine.topic.domain.Publicity; import java.util.List; +import org.springframework.web.multipart.MultipartFile; public record TopicMergeRequest( String name, - String image, + MultipartFile image, String description, Publicity publicity, PermissionType permissionType, List topics ) { + + public static TopicMergeRequest of( + TopicMergeRequestWithoutImage topicMergeRequestWithoutImage, + MultipartFile image + ) { + return new TopicMergeRequest( + topicMergeRequestWithoutImage.name(), + image, + topicMergeRequestWithoutImage.description(), + topicMergeRequestWithoutImage.publicity(), + topicMergeRequestWithoutImage.permissionType(), + topicMergeRequestWithoutImage.topics() + ); + } + } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequestWithoutImage.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequestWithoutImage.java new file mode 100644 index 00000000..a7904b23 --- /dev/null +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/request/TopicMergeRequestWithoutImage.java @@ -0,0 +1,14 @@ +package com.mapbefine.mapbefine.topic.dto.request; + +import com.mapbefine.mapbefine.topic.domain.PermissionType; +import com.mapbefine.mapbefine.topic.domain.Publicity; +import java.util.List; + +public record TopicMergeRequestWithoutImage( + String name, + String description, + Publicity publicity, + PermissionType permissionType, + List topics +) { +} diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicDetailResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicDetailResponse.java index f83563a8..23fefa8a 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicDetailResponse.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicDetailResponse.java @@ -16,10 +16,17 @@ public record TopicDetailResponse( Boolean isInAtlas, Integer bookmarkCount, Boolean isBookmarked, + Boolean canUpdate, LocalDateTime updatedAt, List pins ) { - public static TopicDetailResponse from(Topic topic, Boolean isInAtlas, Boolean isBookmarked) { + + public static TopicDetailResponse of( + Topic topic, + Boolean isInAtlas, + Boolean isBookmarked, + Boolean canUpdate + ) { List pinResponses = topic.getPins().stream() .map(PinResponse::from) .toList(); @@ -36,8 +43,39 @@ public static TopicDetailResponse from(Topic topic, Boolean isInAtlas, Boolean i isInAtlas, topic.countBookmarks(), isBookmarked, - topic.getUpdatedAt(), + canUpdate, + topic.getLastPinUpdatedAt(), pinResponses ); } + + public static TopicDetailResponse fromGuestQuery(Topic topic) { + List pinResponses = topic.getPins().stream() + .map(PinResponse::from) + .toList(); + + TopicInfo topicInfo = topic.getTopicInfo(); + + return new TopicDetailResponse( + topic.getId(), + topicInfo.getName(), + topicInfo.getDescription(), + topicInfo.getImageUrl(), + topic.getCreator().getMemberInfo().getNickName(), + topic.countPins(), + Boolean.FALSE, + topic.countBookmarks(), + Boolean.FALSE, + Boolean.FALSE, + topic.getLastPinUpdatedAt(), + pinResponses + ); + } + + public static List ofGuestQueries(List topics) { + + return topics.stream() + .map(TopicDetailResponse::fromGuestQuery) + .toList(); + } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicResponse.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicResponse.java index 73165788..bd757a43 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicResponse.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/dto/response/TopicResponse.java @@ -15,6 +15,7 @@ public record TopicResponse( Boolean isBookmarked, LocalDateTime updatedAt ) { + public static TopicResponse from(Topic topic, Boolean isInAtlas, Boolean isBookmarked) { TopicInfo topicInfo = topic.getTopicInfo(); @@ -27,7 +28,23 @@ public static TopicResponse from(Topic topic, Boolean isInAtlas, Boolean isBookm isInAtlas, topic.countBookmarks(), isBookmarked, - topic.getUpdatedAt() + topic.getLastPinUpdatedAt() + ); + } + + public static TopicResponse fromGuestQuery(Topic topic) { + TopicInfo topicInfo = topic.getTopicInfo(); + + return new TopicResponse( + topic.getId(), + topicInfo.getName(), + topicInfo.getImageUrl(), + topic.getCreator().getMemberInfo().getNickName(), + topic.countPins(), + Boolean.FALSE, + topic.countBookmarks(), + Boolean.FALSE, + topic.getLastPinUpdatedAt() ); } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicException.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicException.java index 2c2afb9f..e9729943 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicException.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/exception/TopicException.java @@ -23,6 +23,10 @@ public static class TopicNotFoundException extends NotFoundException { public TopicNotFoundException(TopicErrorCode errorCode, List ids) { super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), ids)); } + + public TopicNotFoundException(TopicErrorCode errorCode, Long id) { + super(new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), id)); + } } } diff --git a/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java b/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java index fa1842db..7bb8d169 100644 --- a/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java +++ b/backend/src/main/java/com/mapbefine/mapbefine/topic/presentation/TopicController.java @@ -5,12 +5,15 @@ import com.mapbefine.mapbefine.topic.application.TopicCommandService; import com.mapbefine.mapbefine.topic.application.TopicQueryService; import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest; +import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequest; +import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; import java.net.URI; import java.util.List; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -20,7 +23,9 @@ 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.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/topics") @@ -38,18 +43,34 @@ public TopicController( } @LoginRequired - @PostMapping("/new") - public ResponseEntity create(AuthMember member, @RequestBody TopicCreateRequest request) { - Long topicId = topicCommandService.saveTopic(member, request); + @PostMapping( + value = "/new", + consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE} + ) + public ResponseEntity create( + AuthMember member, + @RequestPart TopicCreateRequestWithoutImage request, + @RequestPart(required = false) MultipartFile image + ) { + TopicCreateRequest topicCreateRequest = TopicCreateRequest.of(request, image); + Long topicId = topicCommandService.saveTopic(member, topicCreateRequest); return ResponseEntity.created(URI.create("/topics/" + topicId)) .build(); } @LoginRequired - @PostMapping("/merge") - public ResponseEntity mergeAndCreate(AuthMember member, @RequestBody TopicMergeRequest request) { - Long topicId = topicCommandService.merge(member, request); + @PostMapping( + value = "/merge", + consumes = {MediaType.APPLICATION_JSON_VALUE, MediaType.MULTIPART_FORM_DATA_VALUE} + ) + public ResponseEntity mergeAndCreate( + AuthMember member, + @RequestPart TopicMergeRequestWithoutImage request, + @RequestPart(required = false) MultipartFile image + ) { + TopicMergeRequest topicMergeRequest = TopicMergeRequest.of(request, image); + Long topicId = topicCommandService.merge(member, topicMergeRequest); return ResponseEntity.created(URI.create("/topics/" + topicId)) .build(); diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml deleted file mode 100644 index 703003e8..00000000 --- a/backend/src/main/resources/application-dev.yml +++ /dev/null @@ -1,40 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${MYSQL_DATABASE_URL} - username: ${MYSQL_DATABASE_USER_NAME} - password: ${MYSQL_DATABASE_PASSWORD} - jpa: - properties: - hibernate: - format_sql: true - show-sql: true - hibernate: - ddl-auto: update -logging: - level: - org: - hibernate: - type: - descriptor: - sql: - BasicBinder: TRACE - file: - # jar 파일 실행 시, jar 파일과 동일한 디렉토리 내 log 디렉토리에 생성됨 - path: /home/ubuntu/backend/build/log - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - -oauth: - kakao: - client_id: ${OAUTH_KAKAO_CLIENT_ID} - redirect_uri: ${OAUTH_KAKAO_REDIRECT_URI} - client_secret: ${OAUTH_KAKAO_CLIENT_SECRET} - scope: ${OAUTH_KAKAO_SCOPE} - -security: - jwt: - token: - secret-key: ${JWT_TOKEN_SECRET_KEY} - expire-length: ${JWT_TOKEN_EXPIRE_LENGTH} diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml deleted file mode 100644 index b88d6f7d..00000000 --- a/backend/src/main/resources/application-prod.yml +++ /dev/null @@ -1,30 +0,0 @@ -spring: - datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${MYSQL_DATABASE_URL} - username: ${MYSQL_DATABASE_USER_NAME} - password: ${MYSQL_DATABASE_PASSWORD} - jpa: - hibernate: - ddl-auto: none - -logging: - file: - # jar 파일 실행 시, jar 파일과 동일한 디렉토리 내 log 디렉토리에 생성됨 - path: /home/ubuntu/backend/build/log - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - -oauth: - kakao: - client_id: ${OAUTH_KAKAO_CLIENT_ID} - redirect_uri: ${OAUTH_KAKAO_REDIRECT_URI} - client_secret: ${OAUTH_KAKAO_CLIENT_SECRET} - scope: ${OAUTH_KAKAO_SCOPE} -security: - jwt: - token: - secret-key: ${JWT_TOKEN_SECRET_KEY} - expire-length: ${JWT_TOKEN_EXPIRE_LENGTH} - diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml deleted file mode 100644 index 6a1d55ec..00000000 --- a/backend/src/main/resources/application.yml +++ /dev/null @@ -1,42 +0,0 @@ -spring: - datasource: - username: sa - url: jdbc:h2:mem:mapbefine;MODE=MySQL - h2: - console: - enabled: true - path: /h2-console - jpa: - properties: - hibernate: - format_sql: true - show-sql: true - hibernate: - ddl-auto: create -logging: - level: - org: - hibernate: - type: - descriptor: - sql: - BasicBinder: TRACE - file: - # jar 파일 실행 시, jar 파일과 동일한 디렉토리 내 log 디렉토리에 생성됨 - path: ./log - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - file: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" - -oauth: - kakao: - client_id: ${OAUTH_KAKAO_CLIENT_ID} - redirect_uri: ${OAUTH_KAKAO_REDIRECT_URI} - client_secret: ${OAUTH_KAKAO_CLIENT_SECRET} - scope: ${OAUTH_KAKAO_SCOPE} - -security: - jwt: - token: - secret-key: ${JWT_TOKEN_SECRET_KEY} - expire-length: ${JWT_TOKEN_EXPIRE_LENGTH} diff --git a/backend/src/main/resources/config b/backend/src/main/resources/config new file mode 160000 index 00000000..8069c700 --- /dev/null +++ b/backend/src/main/resources/config @@ -0,0 +1 @@ +Subproject commit 8069c700c69d2b46b52c34817d8d4f66fe8d70b1 diff --git a/backend/src/main/resources/data-default.sql b/backend/src/main/resources/data-default.sql new file mode 100644 index 00000000..8d6887eb --- /dev/null +++ b/backend/src/main/resources/data-default.sql @@ -0,0 +1,16 @@ +INSERT INTO member (nick_name, email, image_url, role, status, + oauth_server_id, oauth_server_type, + created_at, updated_at) +VALUES ('dummyMember', 'dummy@gmail.com', 'https://map-befine-official.github.io/favicon.png', 'USER', 'NORMAL', + 1L, 'KAKAO', + now(), now()); + +INSERT INTO topic (name, image_url, description,가 + permission_type, publicity, + member_id, + created_at, updated_at, last_pin_updated_at) +VALUES ('dummyTopic', 'https://map-befine-official.github.io/favicon.png', 'description', + 'ALL_MEMBERS', 'PUBLIC', + 1L, + now(), now(), now()) +; diff --git a/backend/src/main/resources/logback-spring.xml b/backend/src/main/resources/logback-spring.xml index da5246e9..944c99dd 100644 --- a/backend/src/main/resources/logback-spring.xml +++ b/backend/src/main/resources/logback-spring.xml @@ -1,51 +1,54 @@ + + + + - - - - - - - - - - ${LOG_PATTERN} - - - - - - - - - WARN - ACCEPT - DENY - - ${LOG_PATH}/warn.log - - ${LOG_PATTERN} - - - - - - - ERROR - ACCEPT - DENY - - - ${LOG_PATH}/error.log - - ${LOG_PATTERN} - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/backend/src/main/resources/logback/console-appender.xml b/backend/src/main/resources/logback/console-appender.xml new file mode 100644 index 00000000..ab72b3b0 --- /dev/null +++ b/backend/src/main/resources/logback/console-appender.xml @@ -0,0 +1,7 @@ + + + + ${LOG_PATTERN_COLORED} + + + diff --git a/backend/src/main/resources/logback/file-debug-appender.xml b/backend/src/main/resources/logback/file-debug-appender.xml new file mode 100644 index 00000000..925177fa --- /dev/null +++ b/backend/src/main/resources/logback/file-debug-appender.xml @@ -0,0 +1,19 @@ + + + ${LOG_PATH}/debug/debug.log + + DEBUG + ACCEPT + DENY + + + ${LOG_PATTERN_DEFAULT} + + + ${LOG_PATH}/debug/debug-%d{yyyy-MM-dd}.%i.log + 100MB + 7 + 1GB + + + diff --git a/backend/src/main/resources/logback/file-error-appender.xml b/backend/src/main/resources/logback/file-error-appender.xml new file mode 100644 index 00000000..7820c08e --- /dev/null +++ b/backend/src/main/resources/logback/file-error-appender.xml @@ -0,0 +1,19 @@ + + + ${LOG_PATH}/error/error.log + + ERROR + ACCEPT + DENY + + + ${LOG_PATTERN_DEFAULT} + + + ${LOG_PATH}/error/error-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + diff --git a/backend/src/main/resources/logback/file-hibernate-appender.xml b/backend/src/main/resources/logback/file-hibernate-appender.xml new file mode 100644 index 00000000..cf66c306 --- /dev/null +++ b/backend/src/main/resources/logback/file-hibernate-appender.xml @@ -0,0 +1,14 @@ + + + ${LOG_PATH}/hibernate/hibernate.log + + ${LOG_PATTERN_DEFAULT} + + + ${LOG_PATH}/hibernate/hibernate-%d{yyyy-MM-dd}.%i.log + 100MB + 7 + 1GB + + + diff --git a/backend/src/main/resources/logback/file-info-appender.xml b/backend/src/main/resources/logback/file-info-appender.xml new file mode 100644 index 00000000..8fc1cdf7 --- /dev/null +++ b/backend/src/main/resources/logback/file-info-appender.xml @@ -0,0 +1,19 @@ + + + ${LOG_PATH}/info/info.log + + INFO + ACCEPT + DENY + + + ${LOG_PATTERN_DEFAULT} + + + ${LOG_PATH}/info/info-%d{yyyy-MM-dd}.%i.log + 100MB + 7 + 1GB + + + diff --git a/backend/src/main/resources/logback/file-warn-appender.xml b/backend/src/main/resources/logback/file-warn-appender.xml new file mode 100644 index 00000000..24ffa0f3 --- /dev/null +++ b/backend/src/main/resources/logback/file-warn-appender.xml @@ -0,0 +1,19 @@ + + + ${LOG_PATH}/warn/warn.log + + WARN + ACCEPT + DENY + + + ${LOG_PATTERN_DEFAULT} + + + ${LOG_PATH}/warn/warn-%d{yyyy-MM-dd}.%i.log + 100MB + 30 + 3GB + + + diff --git a/backend/src/main/resources/logback/slack-error-appender.xml b/backend/src/main/resources/logback/slack-error-appender.xml new file mode 100644 index 00000000..a16bd597 --- /dev/null +++ b/backend/src/main/resources/logback/slack-error-appender.xml @@ -0,0 +1,17 @@ + + + + + ERROR + ACCEPT + DENY + + ${SLACK_INCOMING_WEBHOOK_URI} + ERROR-ALARM + :rotating_light: + true + + ${LOG_PATTERN} + + + diff --git a/backend/src/test/java/com/mapbefine/mapbefine/DatabaseCleanup.java b/backend/src/test/java/com/mapbefine/mapbefine/DatabaseCleanup.java index 91694b9b..d5883ea4 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/DatabaseCleanup.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/DatabaseCleanup.java @@ -1,12 +1,12 @@ package com.mapbefine.mapbefine; -import jakarta.persistence.Entity; -import jakarta.persistence.EntityManager; -import jakarta.persistence.PersistenceContext; -import jakarta.persistence.metamodel.EntityType; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; import java.util.List; -import java.util.Set; import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -14,85 +14,50 @@ public class DatabaseCleanup implements InitializingBean { private static final String TRUNCATE_SQL_MESSAGE = "TRUNCATE TABLE %s"; - private static final String ID_RESET_SQL_MESSAGE = "ALTER TABLE %s ALTER COLUMN ID RESTART WITH 1"; private static final String SET_REFERENTIAL_INTEGRITY_SQL_MESSAGE = "SET REFERENTIAL_INTEGRITY %s"; - private static final String UNDERSCORE = "_"; private static final String DISABLE_REFERENTIAL_QUERY = String.format(SET_REFERENTIAL_INTEGRITY_SQL_MESSAGE, false); private static final String ENABLE_REFERENTIAL_QUERY = String.format(SET_REFERENTIAL_INTEGRITY_SQL_MESSAGE, true); - @PersistenceContext - private EntityManager entityManager; + @Autowired + private JdbcTemplate jdbcTemplate; - private List tableNames; + private List tableNames = new ArrayList<>(); @Override - public void afterPropertiesSet() { - Set> entities = entityManager.getMetamodel() - .getEntities(); + public void afterPropertiesSet() throws SQLException { + ResultSet rs = jdbcTemplate.getDataSource() + .getConnection() + .getMetaData() + .getTables(null, "PUBLIC", null, new String[]{"TABLE"}); - tableNames = entities.stream() - .filter(this::isEntity) - .map(this::convertTableNameFromCamelCaseToSnakeCase) - .toList(); - } - - private boolean isEntity(final EntityType entityType) { - return entityType.getJavaType() - .getAnnotation(Entity.class) != null; - } + while (rs.next()) { + String tableName = rs.getString("TABLE_NAME"); - private String convertTableNameFromCamelCaseToSnakeCase(EntityType entityType) { - StringBuilder tableNameSnake = new StringBuilder(); - String classNameOfEntity = entityType.getName(); - - for (char letter : classNameOfEntity.toCharArray()) { - addUnderScoreForCapitalLetter(tableNameSnake, letter); - tableNameSnake.append(letter); - } - - return tableNameSnake.substring(1).toLowerCase(); - } - - private void addUnderScoreForCapitalLetter(StringBuilder tableNameSnake, char letter) { - if (Character.isUpperCase(letter)) { - tableNameSnake.append(UNDERSCORE); + this.tableNames.add(tableName); } } @Transactional public void execute() { - executeSqlWithReferentialIntegrityDisabled(this::executeTruncate); - } - - private void executeSqlWithReferentialIntegrityDisabled(Runnable sqlExecutor) { disableReferentialIntegrity(); - sqlExecutor.run(); + executeTruncate(); enableReferentialIntegrity(); } private void disableReferentialIntegrity() { - entityManager.flush(); - - entityManager.createNativeQuery(DISABLE_REFERENTIAL_QUERY) - .executeUpdate(); - } - - private void enableReferentialIntegrity() { - - entityManager.createNativeQuery(ENABLE_REFERENTIAL_QUERY) - .executeUpdate(); + jdbcTemplate.execute(DISABLE_REFERENTIAL_QUERY); } private void executeTruncate() { for (String tableName : tableNames) { String TRUNCATE_QUERY = String.format(TRUNCATE_SQL_MESSAGE, tableName); - String ID_RESET_QUERY = String.format(ID_RESET_SQL_MESSAGE, tableName); - entityManager.createNativeQuery(TRUNCATE_QUERY) - .executeUpdate(); - entityManager.createNativeQuery(ID_RESET_QUERY) - .executeUpdate(); + jdbcTemplate.execute(TRUNCATE_QUERY); } } + private void enableReferentialIntegrity() { + jdbcTemplate.execute(ENABLE_REFERENTIAL_QUERY); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java new file mode 100644 index 00000000..c7d7c26f --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/AdminIntegrationTest.java @@ -0,0 +1,249 @@ +package com.mapbefine.mapbefine.admin; + +import static io.restassured.RestAssured.given; +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; +import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; +import com.mapbefine.mapbefine.common.IntegrationTest; +import com.mapbefine.mapbefine.location.LocationFixture; +import com.mapbefine.mapbefine.location.domain.Location; +import com.mapbefine.mapbefine.location.domain.LocationRepository; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.PinImageFixture; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.domain.PinImage; +import com.mapbefine.mapbefine.pin.domain.PinImageRepository; +import com.mapbefine.mapbefine.pin.domain.PinRepository; +import com.mapbefine.mapbefine.topic.TopicFixture; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import io.restassured.common.mapper.TypeRef; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; + +class AdminIntegrationTest extends IntegrationTest { + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TopicRepository topicRepository; + + @Autowired + private PinRepository pinRepository; + + @Autowired + private LocationRepository locationRepository; + + @Autowired + private PinImageRepository pinImageRepository; + + private Location location; + private Member admin; + private Member member; + private Topic topic; + private Pin pin; + private PinImage pinImage; + + @BeforeEach + void setup() { + admin = memberRepository.save(MemberFixture.create("Admin", "admin@naver.com", Role.ADMIN)); + member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + topic = topicRepository.save(TopicFixture.createByName("topic", member)); + location = locationRepository.save(LocationFixture.create()); + pin = pinRepository.save(PinFixture.create(location, topic, member)); + pinImage = pinImageRepository.save(PinImageFixture.create(pin)); + } + + @Test + @DisplayName("어드민일 경우, 회원을 전체 조회할 수 있다.") + void findAllMembers_Success() { + //given + Member member1 = MemberFixture.create("member1", "member1@gmail.com", Role.USER); + Member member2 = MemberFixture.create("member2", "member2@gmail.com", Role.USER); + memberRepository.save(member1); + memberRepository.save(member2); + + //when + List response = given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .accept(MediaType.APPLICATION_JSON_VALUE) + .when().get("/admin/members") + .then().log().all() + .extract() + .as(new TypeRef<>() { + }); + + //then + List expected = List.of( + AdminMemberResponse.from(member), + AdminMemberResponse.from(member1), + AdminMemberResponse.from(member2) + ); + + assertThat(response).usingRecursiveComparison() + .ignoringFields("updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("어드민이 아닐 경우, 회원을 전체 조회할 수 없다.") + void findAllMembers_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .accept(MediaType.APPLICATION_JSON_VALUE) + .when().get("/admin/members/" + member.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 회원의 상세 정보를 조회할 수 있다.") + void findMemberDetail_Success() { + //given when + AdminMemberDetailResponse response = given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .accept(MediaType.APPLICATION_JSON_VALUE) + .when().get("/admin/members/" + member.getId()) + .then().log().all() + .extract() + .as(new TypeRef<>() { + }); + + //then + + AdminMemberDetailResponse expected = AdminMemberDetailResponse.of( + member, + member.getCreatedTopics(), + member.getCreatedPins() + ); + + assertThat(response).usingRecursiveComparison() + .ignoringFields("updatedAt") + .ignoringFields("topics.updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 회원의 상세 정보를 조회할 수 없다.") + void findMemberDetail_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .accept(MediaType.APPLICATION_JSON_VALUE) + .when().get("/admin/members/" + member.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 회원을 삭제(차단)할 수 있다.") + void deleteMember_Success() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .when().delete("/admin/members/" + member.getId()) + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 회원을 삭제(차단)할 수 없다.") + void deleteMember_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .when().delete("/admin/members/" + member.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 토픽을 삭제할 수 있다.") + void deleteTopic_Success() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .when().delete("/admin/topics/" + topic.getId()) + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 토픽을 삭제할 수 없다.") + void deleteTopic_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .when().delete("/admin/topics/" + topic.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 토픽 이미지를 삭제할 수 있다.") + void deleteTopicImage_Success() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .when().delete("/admin/topics/" + topic.getId() + "/images") + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 토픽 이미지를 삭제할 수 없다.") + void deleteTopicImage_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .when().delete("/admin/topics/" + topic.getId() + "/images") + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 핀을 삭제할 수 있다.") + void deletePin_Success() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .when().delete("/admin/pins/" + pin.getId()) + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 핀을 삭제할 수 없다.") + void deletePin_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .when().delete("/admin/pins/" + pin.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + + @Test + @DisplayName("어드민일 경우, 특정 핀 이미지를 삭제할 수 있다.") + void deletePinImage_Success() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(admin)) + .when().delete("/admin/pins/images/" + pinImage.getId()) + .then().log().all() + .statusCode(HttpStatus.NO_CONTENT.value()); + } + + @Test + @DisplayName("어드민이 아닐 경우, 특정 핀 이미지를 삭제할 수 없다.") + void deletePinImage_Fail() { + given().log().all() + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeader(member)) + .when().delete("/admin/pins/images/" + pinImage.getId()) + .then().log().all() + .statusCode(HttpStatus.FORBIDDEN.value()); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java new file mode 100644 index 00000000..0f3dfcba --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminCommandServiceTest.java @@ -0,0 +1,281 @@ +package com.mapbefine.mapbefine.admin.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.mapbefine.mapbefine.atlas.domain.Atlas; +import com.mapbefine.mapbefine.atlas.domain.AtlasRepository; +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.bookmark.domain.Bookmark; +import com.mapbefine.mapbefine.bookmark.domain.BookmarkRepository; +import com.mapbefine.mapbefine.common.annotation.ServiceTest; +import com.mapbefine.mapbefine.location.LocationFixture; +import com.mapbefine.mapbefine.location.domain.Location; +import com.mapbefine.mapbefine.location.domain.LocationRepository; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.member.domain.Status; +import com.mapbefine.mapbefine.permission.domain.Permission; +import com.mapbefine.mapbefine.permission.domain.PermissionRepository; +import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; +import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.PinImageFixture; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.domain.PinImage; +import com.mapbefine.mapbefine.pin.domain.PinImageRepository; +import com.mapbefine.mapbefine.pin.domain.PinRepository; +import com.mapbefine.mapbefine.topic.TopicFixture; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.domain.TopicInfo; +import com.mapbefine.mapbefine.topic.domain.TopicRepository; +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; + +@ServiceTest +class AdminCommandServiceTest { + + @Autowired + private AdminCommandService adminCommandService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TopicRepository topicRepository; + + @Autowired + private PinRepository pinRepository; + + @Autowired + private LocationRepository locationRepository; + + @Autowired + private PinImageRepository pinImageRepository; + + @Autowired + private AtlasRepository atlasRepository; + + @Autowired + private PermissionRepository permissionRepository; + + @Autowired + private BookmarkRepository bookmarkRepository; + + private Location location; + private Member admin; + private Member member; + private Topic topic; + private Pin pin; + private PinImage pinImage; + + @BeforeEach + void setup() { + admin = memberRepository.save(MemberFixture.create("Admin", "admin@naver.com", Role.ADMIN)); + member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + topic = topicRepository.save(TopicFixture.createByName("topic", member)); + location = locationRepository.save(LocationFixture.create()); + pin = pinRepository.save(PinFixture.create(location, topic, member)); + pinImage = pinImageRepository.save(PinImageFixture.create(pin)); + } + + @DisplayName("Member를 차단(탈퇴시킬)할 경우, Member가 생성한 토픽, 핀, 핀 이미지가 soft-deleting 된다.") + @Test + void blockMember_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + Bookmark bookmark = Bookmark.createWithAssociatedTopicAndMember(topic, member); + Atlas atlas = Atlas.createWithAssociatedMember(topic, member); + Permission permission = Permission.createPermissionAssociatedWithTopicAndMember(topic, member); + + bookmarkRepository.save(bookmark); + atlasRepository.save(atlas); + permissionRepository.save(permission); + + assertAll(() -> { + assertThat(member.getMemberInfo().getStatus()).isEqualTo(Status.NORMAL); + assertThat(topic.isDeleted()).isFalse(); + assertThat(pin.isDeleted()).isFalse(); + assertThat(pinImage.isDeleted()).isFalse(); + assertThat(bookmarkRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId())).isTrue(); + assertThat(atlasRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId())).isTrue(); + assertThat(permissionRepository.existsByTopicIdAndMemberId(topic.getId(), member.getId())).isTrue(); + }); + + //when + adminCommandService.blockMember(adminAuthMember, member.getId()); + + //then + Topic deletedTopic = topicRepository.findById(topic.getId()).get(); + Pin deletedPin = pinRepository.findById(pin.getId()).get(); + PinImage deletedPinImage = pinImageRepository.findById(pinImage.getId()).get(); + + assertAll(() -> { + assertThat(member.getMemberInfo().getStatus()).isEqualTo(Status.BLOCKED); + assertThat(deletedTopic.isDeleted()).isTrue(); + assertThat(deletedPin.isDeleted()).isTrue(); + assertThat(deletedPinImage.isDeleted()).isTrue(); + assertThat(bookmarkRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId())).isFalse(); + assertThat(atlasRepository.existsByMemberIdAndTopicId(member.getId(), topic.getId())).isFalse(); + assertThat(permissionRepository.existsByTopicIdAndMemberId(topic.getId(), member.getId())).isFalse(); + }); + } + + @DisplayName("Admin이 아닐 경우, Member를 차단(탈퇴시킬)할 수 없다.") + @Test + void blockMember_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + Member otherMember = MemberFixture.create("otherMember", "otherMember@email.com", Role.USER); + memberRepository.save(otherMember); + + assertAll(() -> { + assertThat(member.getMemberInfo().getStatus()).isEqualTo(Status.NORMAL); + assertThat(topic.isDeleted()).isFalse(); + assertThat(pin.isDeleted()).isFalse(); + assertThat(pinImage.isDeleted()).isFalse(); + }); + + //when then + Long otherMemberId = otherMember.getId(); + assertThatThrownBy(() -> adminCommandService.blockMember(userAuthMember, otherMemberId)) + .isInstanceOf(PermissionForbiddenException.class); + } + + @DisplayName("Admin은 토픽을 삭제시킬 수 있다.") + @Test + void deleteTopic_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + + assertThat(topic.isDeleted()).isFalse(); + + //when + adminCommandService.deleteTopic(adminAuthMember, topic.getId()); + + //then + Topic deletedTopic = topicRepository.findById(topic.getId()).get(); + + assertThat(deletedTopic.isDeleted()).isTrue(); + } + + @DisplayName("Admin이 아닐 경우, 토픽을 삭제시킬 수 없다.") + @Test + void deleteTopic_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + + assertThat(topic.isDeleted()).isFalse(); + + //when then + Long topicId = topic.getId(); + assertThatThrownBy(() -> adminCommandService.deleteTopic(userAuthMember, topicId)) + .isInstanceOf(PermissionForbiddenException.class); + } + + @DisplayName("Admin은 토픽 이미지를 삭제할 수 있다.") + @Test + void deleteTopicImage_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + TopicInfo topicInfo = topic.getTopicInfo(); + + topic.updateTopicInfo(topicInfo.getName(), topicInfo.getDescription(), "https://imageUrl.png"); + + assertThat(topic.getTopicInfo().getImageUrl()).isEqualTo("https://imageUrl.png"); + + //when + adminCommandService.deleteTopicImage(adminAuthMember, topic.getId()); + + //then + Topic imageDeletedTopic = topicRepository.findById(topic.getId()).get(); + + assertThat(imageDeletedTopic.getTopicInfo().getImageUrl()).isEqualTo( + "https://velog.velcdn.com/images/semnil5202/post/37f3bcb9-0b07-4100-85f6-f1d5ad037c14/image.svg" + ); + } + + @DisplayName("Admin이 아닐 경우, 이미지를 삭제할 수 없다.") + @Test + void deleteTopicImage_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + TopicInfo topicInfo = topic.getTopicInfo(); + + topic.updateTopicInfo(topicInfo.getName(), topicInfo.getDescription(), "https://imageUrl.png"); + + assertThat(topic.getTopicInfo().getImageUrl()).isEqualTo("https://imageUrl.png"); + + //when then + Long topicId = topic.getId(); + assertThatThrownBy(() -> adminCommandService.deleteTopicImage(userAuthMember, topicId)) + .isInstanceOf(PermissionForbiddenException.class); + } + + @DisplayName("Admin은 핀을 삭제할 수 있다.") + @Test + void deletePin_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + + assertThat(pin.isDeleted()).isFalse(); + + //when + adminCommandService.deletePin(adminAuthMember, pin.getId()); + + //then + Pin deletedPin = pinRepository.findById(pin.getId()).get(); + + assertThat(deletedPin.isDeleted()).isTrue(); + } + + @DisplayName("Admin이 아닐 경우, 핀을 삭제할 수 없다.") + @Test + void deletePin_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + + assertThat(pin.isDeleted()).isFalse(); + + //when then + Long pinId = pin.getId(); + assertThatThrownBy(() -> adminCommandService.deletePin(userAuthMember, pinId)) + .isInstanceOf(PermissionForbiddenException.class); + } + + @DisplayName("Admin인 경우, 핀 이미지를 삭제할 수 있다.") + @Test + void deletePinImage_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + + assertThat(pinImage.isDeleted()).isFalse(); + + //when + adminCommandService.deletePinImage(adminAuthMember, pinImage.getId()); + + //then + PinImage deletedPinImage = pinImageRepository.findById(pinImage.getId()).get(); + + assertThat(deletedPinImage.isDeleted()).isTrue(); + } + + @DisplayName("Admin이 아닐 경우, 핀 이미지를 삭제할 수 없다.") + @Test + void deletePinImage_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + + assertThat(pinImage.isDeleted()).isFalse(); + + //when then + Long pinImageId = pinImage.getId(); + assertThatThrownBy(() -> adminCommandService.deletePinImage(userAuthMember, pinImageId)) + .isInstanceOf(PermissionForbiddenException.class); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminQueryServiceTest.java new file mode 100644 index 00000000..33599110 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/application/AdminQueryServiceTest.java @@ -0,0 +1,135 @@ +package com.mapbefine.mapbefine.admin.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; +import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.common.annotation.ServiceTest; +import com.mapbefine.mapbefine.location.LocationFixture; +import com.mapbefine.mapbefine.location.domain.Location; +import com.mapbefine.mapbefine.location.domain.LocationRepository; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionForbiddenException; +import com.mapbefine.mapbefine.pin.PinFixture; +import com.mapbefine.mapbefine.pin.domain.Pin; +import com.mapbefine.mapbefine.pin.domain.PinRepository; +import com.mapbefine.mapbefine.topic.TopicFixture; +import com.mapbefine.mapbefine.topic.domain.Topic; +import com.mapbefine.mapbefine.topic.domain.TopicRepository; +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; + +@ServiceTest +class AdminQueryServiceTest { + + @Autowired + private AdminQueryService adminQueryService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private TopicRepository topicRepository; + + @Autowired + private PinRepository pinRepository; + + @Autowired + private LocationRepository locationRepository; + + private Location location; + private Member admin; + private Member member; + private Topic topic; + private Pin pin; + + @BeforeEach + void setup() { + admin = memberRepository.save(MemberFixture.create("Admin", "admin@naver.com", Role.ADMIN)); + member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + topic = topicRepository.save(TopicFixture.createByName("topic", member)); + location = locationRepository.save(LocationFixture.create()); + pin = pinRepository.save(PinFixture.create(location, topic, member)); + } + + @Test + @DisplayName("사용자와 관련된 세부(민감한 정보 포함) 정보를 모두 조회할 수 있다.") + void findMemberDetail_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + + //when + AdminMemberDetailResponse response = adminQueryService.findMemberDetail(adminAuthMember, member.getId()); + + //then + assertThat(response).usingRecursiveComparison() + .ignoringFields("updatedAt") + .isEqualTo(AdminMemberDetailResponse.of(member, List.of(topic), List.of(pin))); + } + + @Test + @DisplayName("Admin이 아닌 경우, 사용자와 관련된 세부(민감한 정보 포함) 정보를 모두 조회할 수 없다.") + void findMemberDetail_Fail() { + //given + AuthMember userAuthMember = MemberFixture.createUser(member); + + //when //then + Long memberId = member.getId(); + assertThatThrownBy(() -> adminQueryService.findMemberDetail(userAuthMember, memberId)) + .isInstanceOf(PermissionForbiddenException.class); + } + + @Test + @DisplayName("모든 사용자와 관련된 세부 정보를 모두 조회할 수 있다.") + void findAllMemberDetails_Success() { + //given + AuthMember adminAuthMember = MemberFixture.createUser(admin); + + ArrayList members = new ArrayList<>(); + members.add(member); + for (int i = 0; i < 10; i++) { + Member saveMember = MemberFixture.create("member" + i, "member" + i + "@email.com", Role.USER); + members.add(memberRepository.save(saveMember)); + } + + //when + List responses = adminQueryService.findAllMemberDetails(adminAuthMember); + //then + List expected = members.stream() + .map(AdminMemberResponse::from) + .toList(); + + assertThat(responses).usingRecursiveComparison() + .ignoringFields("updatedAt") + .ignoringCollectionOrderInFields() + .isEqualTo(expected); + } + + @Test + @DisplayName("Admin이 아닌 경우 모든 사용자와 관련된 세부 정보를 모두 조회할 수 없다.") + void findAllMemberDetails_Fail() { + //given + AuthMember memberAuthMember = MemberFixture.createUser(member); + + ArrayList members = new ArrayList<>(); + members.add(member); + for (int i = 0; i < 10; i++) { + Member saveMember = MemberFixture.create("member" + i, "member" + i + "@email.com", Role.USER); + members.add(memberRepository.save(saveMember)); + } + + //when //then + assertThatThrownBy(() -> adminQueryService.findAllMemberDetails(memberAuthMember)) + .isInstanceOf(PermissionForbiddenException.class); + } + +} \ No newline at end of file diff --git a/backend/src/test/java/com/mapbefine/mapbefine/admin/presentation/AdminControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/admin/presentation/AdminControllerTest.java new file mode 100644 index 00000000..edceb5d3 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/admin/presentation/AdminControllerTest.java @@ -0,0 +1,172 @@ +package com.mapbefine.mapbefine.admin.presentation; + +import static org.apache.http.HttpHeaders.AUTHORIZATION; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doNothing; + +import com.mapbefine.mapbefine.admin.application.AdminCommandService; +import com.mapbefine.mapbefine.admin.application.AdminQueryService; +import com.mapbefine.mapbefine.admin.dto.AdminMemberDetailResponse; +import com.mapbefine.mapbefine.admin.dto.AdminMemberResponse; +import com.mapbefine.mapbefine.common.RestDocsIntegration; +import com.mapbefine.mapbefine.common.interceptor.AdminAuthInterceptor; +import com.mapbefine.mapbefine.pin.dto.response.PinResponse; +import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +class AdminControllerTest extends RestDocsIntegration { + + private static final List TOPIC_RESPONSES = List.of(new TopicResponse( + 1L, + "준팍의 또 토픽", + "https://map-befine-official.github.io/favicon.png", + "준팍", + 3, + Boolean.FALSE, + 5, + Boolean.FALSE, + LocalDateTime.now() + ), new TopicResponse( + 2L, + "준팍의 두번째 토픽", + "https://map-befine-official.github.io/favicon.png", + "준팍", + 5, + Boolean.FALSE, + 3, + Boolean.FALSE, + LocalDateTime.now() + )); + private static final List PIN_RESPONSES = List.of( + new PinResponse( + 1L, + "매튜의 산스장", + "지번 주소", + "매튜가 사랑하는 산스장", + "매튜", + 37, + 127 + ), new PinResponse( + 2L, + "매튜의 안갈집", + "지번 주소", + "매튜가 두번은 안 갈 집", + "매튜", + 37, + 127 + ) + ); + + @MockBean + private AdminCommandService adminCommandService; + + @MockBean + private AdminQueryService adminQueryService; + + @MockBean + private AdminAuthInterceptor adminAuthInterceptor; + + @BeforeEach + void setAll() throws Exception { + given(adminAuthInterceptor.preHandle(any(), any(), any())).willReturn(true); + } + + @DisplayName("멤버 목록 조회") + @Test + void findAllMemberDetails() throws Exception { + List response = List.of( + new AdminMemberResponse(1L, "쥬니", "zuny@naver.com", "https://zuny.png", LocalDateTime.now()), + new AdminMemberResponse(2L, "세인", "semin@naver.com", "https://semin.png", LocalDateTime.now()) + ); + + given(adminQueryService.findAllMemberDetails(any())).willReturn(response); + + mockMvc.perform( + MockMvcRequestBuilders.get("/admin/members") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("멤버 상세 조회") + @Test + void findMember() throws Exception { + AdminMemberDetailResponse response = new AdminMemberDetailResponse( + 1L, + "쥬니", + "zuny@naver.com", + "https://image.png", + TOPIC_RESPONSES, + PIN_RESPONSES, + LocalDateTime.now() + ); + + given(adminQueryService.findMemberDetail(any(), any())).willReturn(response); + + mockMvc.perform( + MockMvcRequestBuilders.get("/admin/members/1") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("멤버 차단(블랙리스트)") + @Test + void deleteMember() throws Exception { + doNothing().when(adminCommandService).blockMember(any(), any()); + + mockMvc.perform( + MockMvcRequestBuilders.delete("/admin/members/1") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("토픽 삭제") + @Test + void deleteTopic() throws Exception { + doNothing().when(adminCommandService).deleteTopic(any(), any()); + + mockMvc.perform( + MockMvcRequestBuilders.delete("/admin/topics/1") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("토픽 이미지 삭제") + @Test + void deleteTopicImage() throws Exception { + doNothing().when(adminCommandService).deleteTopicImage(any(), any()); + + mockMvc.perform( + MockMvcRequestBuilders.delete("/admin/topics/1/images") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("핀 삭제") + @Test + void deletePin() throws Exception { + doNothing().when(adminCommandService).deletePin(any(), any()); + + mockMvc.perform( + MockMvcRequestBuilders.delete("/admin/pins/1") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } + + @DisplayName("토픽 이미지 삭제") + @Test + void deletePinImage() throws Exception { + doNothing().when(adminCommandService).deletePinImage(any(), any()); + + mockMvc.perform( + MockMvcRequestBuilders.delete("/admin/pins/images/1") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + ).andDo(restDocs.document()); + } +} \ No newline at end of file diff --git a/backend/src/test/java/com/mapbefine/mapbefine/atlas/AtlasIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/atlas/AtlasIntegrationTest.java index fa681250..57fb46dc 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/atlas/AtlasIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/atlas/AtlasIntegrationTest.java @@ -15,16 +15,15 @@ import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.*; +import io.restassured.response.*; 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.http.HttpStatus; -public class AtlasIntegrationTest extends IntegrationTest { +class AtlasIntegrationTest extends IntegrationTest { @Autowired TopicRepository topicRepository; diff --git a/backend/src/test/java/com/mapbefine/mapbefine/atlas/application/AtlasCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/atlas/application/AtlasCommandServiceTest.java index ba20c8b5..ca10b0b6 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/atlas/application/AtlasCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/atlas/application/AtlasCommandServiceTest.java @@ -84,7 +84,7 @@ void alreadyAdd_Fail() { List actual = actualMember.getAtlantes(); //then - assertThat(actual.size()).isEqualTo(expected.size()); + assertThat(actual).hasSameSizeAs(expected); } @Test @@ -105,7 +105,7 @@ void validateReadPermission_fail() { } @Test - @DisplayName("멤버 ID와 TopicId가 있을 경우, atlas에서 해당 topic을 비운다.") + @DisplayName("회원 ID와 TopicId가 있을 경우, atlas에서 해당 topic을 비운다.") void remove_Success() { Long topicId = topic.getId(); Long memberId = authMember.getMemberId(); @@ -124,7 +124,7 @@ void remove_Success() { List actual = memberAfter.getAtlantes(); //then - assertThat(actual.size()).isEqualTo(expected.size() - 1); + assertThat(actual).hasSize(expected.size() - 1); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TestTokenProvider.java b/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TestTokenProvider.java new file mode 100644 index 00000000..9d5f81d7 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TestTokenProvider.java @@ -0,0 +1,103 @@ +package com.mapbefine.mapbefine.auth.application; + +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.EXPIRED_TOKEN; +import static com.mapbefine.mapbefine.auth.exception.AuthErrorCode.ILLEGAL_TOKEN; + +import com.mapbefine.mapbefine.auth.exception.AuthException.AuthUnauthorizedException; +import com.mapbefine.mapbefine.auth.infrastructure.TokenProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.util.Date; + +public class TestTokenProvider implements TokenProvider { + + private static final String EMPTY = ""; + + private final String secretKey; + private final Date issuedAt; + private final long accessExpirationTime; + private final long refreshExpirationTime; + + public TestTokenProvider( + String secretKey, + Date issuedAt, + long accessExpirationTime, + long refreshExpirationTime + ) { + this.secretKey = secretKey; + this.issuedAt = issuedAt; + this.accessExpirationTime = accessExpirationTime; + this.refreshExpirationTime = refreshExpirationTime; + } + + @Override + public String createAccessToken(String payload) { + return createToken(payload, new Date(), accessExpirationTime); + } + + @Override + public String createRefreshToken() { + return createToken(EMPTY, new Date(), refreshExpirationTime); + } + + public String createExpiredAccessToken(String payload) { + return createToken(payload, issuedAt, accessExpirationTime); + } + + private String createToken(String payload, Date issuedAt, Long validityInMilliseconds) { + Claims claims = Jwts.claims().setSubject(payload); + Date validity = new Date(issuedAt.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(issuedAt) + .setExpiration(validity) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + @Override + public String getPayload(String token) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + } + + @Override + public void validateTokensForReissue(String refreshToken, String accessToken) { + boolean canReissueAccessToken = !isExpired(refreshToken) && isExpired(accessToken); + if (!canReissueAccessToken) { + throw new AuthUnauthorizedException(ILLEGAL_TOKEN); + } + } + + @Override + public void validateTokensForRemoval(String refreshToken, String accessToken) { + boolean canRemoveRefreshToken = !isExpired(refreshToken) && !isExpired(accessToken); + if (!canRemoveRefreshToken) { + throw new AuthUnauthorizedException(EXPIRED_TOKEN); + } + } + + @Override + public void validateAccessToken(String accessToken) { + if (isExpired(accessToken)) { + throw new AuthUnauthorizedException(EXPIRED_TOKEN); + } + } + + private boolean isExpired(String token) { + try { + Jws claims = Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token); + Date expiration = claims.getBody().getExpiration(); + + return expiration.before(new Date()); + } catch (ExpiredJwtException e) { + return true; + } catch (JwtException | IllegalArgumentException e) { + throw new AuthUnauthorizedException(ILLEGAL_TOKEN); + } + } +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TokenServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TokenServiceTest.java new file mode 100644 index 00000000..10b782f8 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/auth/application/TokenServiceTest.java @@ -0,0 +1,146 @@ +package com.mapbefine.mapbefine.auth.application; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.mapbefine.mapbefine.auth.domain.token.RefreshToken; +import com.mapbefine.mapbefine.auth.domain.token.RefreshTokenRepository; +import com.mapbefine.mapbefine.auth.dto.LoginTokens; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import java.util.Calendar; +import java.util.Date; +import java.util.Optional; +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.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.TestPropertySource; + +@DataJpaTest +@TestPropertySource(locations = "classpath:application.yml") +class TokenServiceTest { + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + @Autowired + private MemberRepository memberRepository; + + @Value("${security.jwt.token.secret-key}") + private String secretKey; + @Value("${security.jwt.token.access-expire-length}") + private long accessExpirationTime; + @Value("${security.jwt.token.refresh-expire-length}") + private long refreshExpirationTime; + + private TestTokenProvider testTokenProvider; + private TokenService tokenService; + private Long memberId; + + @BeforeEach + void setUp() { + testTokenProvider = getTestTokenProvider(); + tokenService = new TokenService(testTokenProvider, refreshTokenRepository); + + Member member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + memberId = member.getId(); + } + + private TestTokenProvider getTestTokenProvider() { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DATE, -1); + Date yesterday = calendar.getTime(); + + return new TestTokenProvider( + secretKey, + yesterday, + accessExpirationTime, + refreshExpirationTime + ); + } + + @Nested + @DisplayName("Token을 발급할 때,") + class IssueTokens { + + @Test + @DisplayName("멤버 Id를 입력하면 정상적으로 토큰을 발행한다.") + void success() { + // given + String payload = String.valueOf(memberId); + + // when + LoginTokens loginTokens = tokenService.issueTokens(memberId); + + // then + String actualAccessToken = loginTokens.accessToken(); + String actualRefreshToken = loginTokens.refreshToken(); + + assertThat(actualAccessToken).isNotNull(); + assertThat(actualRefreshToken).isNotNull(); + assertThat(payload).isEqualTo(testTokenProvider.getPayload(actualAccessToken)); + } + + } + + + @Nested + @DisplayName("Token을 재발급할 때,") + class ReissueToken { + + @Test + @DisplayName("Refresh Token이 유효하고, AccessToken이 만료되었다면 성공한다") + void success() { + // given + String payload = String.valueOf(memberId); + String refreshToken = createRefreshToken(); + String accessToken = testTokenProvider.createExpiredAccessToken(payload); + + // when + LoginTokens loginTokens = tokenService.reissueToken(refreshToken, accessToken); + + // then + String actualAccessToken = loginTokens.accessToken(); + String actualRefreshToken = loginTokens.refreshToken(); + + assertThat(actualAccessToken).isNotNull(); + assertThat(actualRefreshToken).isNotNull(); + assertThat(payload).isEqualTo(testTokenProvider.getPayload(actualAccessToken)); + } + + } + + @Nested + @DisplayName("Refresh Token을 제거할 때,") + class RemoveRefreshToken { + + @Test + @DisplayName("Refresh Token과 AccessToken 모두 유효하다면 성공한다") + void removeRefreshToken_success() { + // given + String refreshToken = createRefreshToken(); + String accessToken = testTokenProvider.createAccessToken(String.valueOf(memberId)); + + // when + tokenService.removeRefreshToken(refreshToken, accessToken); + + // then + Optional optionalRefreshToken = refreshTokenRepository.findById(refreshToken); + + assertThat(optionalRefreshToken).isEmpty(); + } + + } + + private String createRefreshToken() { + String refreshToken = testTokenProvider.createRefreshToken(); + refreshTokenRepository.save(new RefreshToken(refreshToken, memberId)); + return refreshToken; + } + + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProviderTest.java b/backend/src/test/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProviderTest.java new file mode 100644 index 00000000..4de1efec --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/auth/infrastructure/JwtTokenProviderTest.java @@ -0,0 +1,29 @@ +package com.mapbefine.mapbefine.auth.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +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.test.context.TestPropertySource; + +@SpringBootTest(classes = JwtTokenProvider.class) +@TestPropertySource(locations = "classpath:application.yml") +class JwtTokenProviderTest { + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Test + @DisplayName("payload를 받아 access token을 생성한다.") + void createAccessToken_success() { + String payload = "1"; + + String token = jwtTokenProvider.createAccessToken(payload); + + assertThat(jwtTokenProvider.getPayload(token)) + .isEqualTo(payload); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/auth/presentation/LoginControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/auth/presentation/LoginControllerTest.java new file mode 100644 index 00000000..cf4febe0 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/auth/presentation/LoginControllerTest.java @@ -0,0 +1,110 @@ +package com.mapbefine.mapbefine.auth.presentation; + + +import static com.mapbefine.mapbefine.oauth.domain.OauthServerType.KAKAO; +import static org.apache.http.cookie.SM.COOKIE; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import com.mapbefine.mapbefine.auth.application.TokenService; +import com.mapbefine.mapbefine.auth.dto.AccessToken; +import com.mapbefine.mapbefine.auth.dto.LoginTokens; +import com.mapbefine.mapbefine.common.RestDocsIntegration; +import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; +import com.mapbefine.mapbefine.oauth.application.OauthService; +import java.time.LocalDateTime; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + + +class LoginControllerTest extends RestDocsIntegration { + + @MockBean + private TokenService tokenService; + @MockBean + private OauthService oauthService; + + + @Test + @DisplayName("kakao 소셜 로그인 Redirection Url 반환") + void redirection() throws Exception { + // given + given(oauthService.getAuthCodeRequestUrl(KAKAO)).willReturn( + "https://kauth.kakao.com/oauth/authorize?" + + "response_type=code" + + "&client_id={client_id}" + + "&redirect_uri={redirection_uri}" + + "&scope={scope}" + ); + + // when then + mockMvc.perform( + MockMvcRequestBuilders.get("/oauth/kakao") + ).andDo(restDocs.document()); + } + + @Test + @DisplayName("소셜 로그인 성공시 로그인한 유저 정보 반환") + void login() throws Exception { + // given + String code = "auth_code"; + + MemberDetailResponse memberDetailResponse = new MemberDetailResponse( + Long.MAX_VALUE, + "모험가03fcb0d", + "yshert@naver.com", + "https://map-befine-official.github.io/favicon.png", + LocalDateTime.now() + ); + + LoginTokens loginTokens = new LoginTokens( + testAuthHeaderProvider.createResponseAccessTokenById(Long.MAX_VALUE), + testAuthHeaderProvider.createRefreshToken() + ); + + given(oauthService.login(KAKAO, code)).willReturn(memberDetailResponse); + given(tokenService.issueTokens(memberDetailResponse.id())).willReturn(loginTokens); + + // when then + mockMvc.perform( + MockMvcRequestBuilders.get("/oauth/login/kakao") + .param("code", code) + ).andDo(restDocs.document()); + } + + @Test + @DisplayName("만료된 Access Token과 유효한 RefreshToken인 경우, 새로운 토큰들을 발행한다.") + void reissueTokens() throws Exception { + AccessToken expiredAccessToken = new AccessToken("만료된 액세스 토큰"); + String refreshToken = "리프레시 토큰"; + + LoginTokens reissuedTokens = new LoginTokens("재발급된 액세스 토큰", "재발급된 리프레시 토큰"); + + given(tokenService.reissueToken(any(), any())).willReturn(reissuedTokens); + + // then + mockMvc.perform( + MockMvcRequestBuilders.post("/refresh-token") + .header(COOKIE, refreshToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(expiredAccessToken)) + ).andDo(restDocs.document()); + } + + @Test + @DisplayName("유효한 Access Token과 유효한 RefreshToken인 경우, RefreshTokenRepository에서 RefreshTokend을 삭제한다") + void logout() throws Exception { + AccessToken accessToken = new AccessToken("access token"); + + mockMvc.perform( + MockMvcRequestBuilders.post("/logout") + .header(COOKIE, testAuthHeaderProvider.createRefreshToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(accessToken)) + ).andDo(restDocs.document()); + + } +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/BookmarkIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/BookmarkIntegrationTest.java index bbe7e6ff..aecb86d0 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/BookmarkIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/BookmarkIntegrationTest.java @@ -1,6 +1,6 @@ package com.mapbefine.mapbefine.bookmark; -import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.*; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.Assertions.assertThat; @@ -14,14 +14,13 @@ import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.response.*; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; -public class BookmarkIntegrationTest extends IntegrationTest { +class BookmarkIntegrationTest extends IntegrationTest { @Autowired private MemberRepository memberRepository; @@ -33,7 +32,7 @@ public class BookmarkIntegrationTest extends IntegrationTest { private BookmarkRepository bookmarkRepository; @Test - @DisplayName("유저가 토픽을 즐겨찾기 목록에 추가하면, 201을 반환한다.") + @DisplayName("회원이 토픽을 즐겨찾기 목록에 추가하면, 201을 반환한다.") void addTopicInBookmark_Success() { //given Member creator = MemberFixture.create("member", "member@naver.com", Role.USER); @@ -63,7 +62,7 @@ void addTopicInBookmark_Success() { } @Test - @DisplayName("유저의 즐겨찾기 토픽을 삭제하면, 204를 반환한다.") + @DisplayName("회원의 즐겨찾기 토픽을 삭제하면, 204를 반환한다.") void deleteTopicInBookmark_Success() { //given Member creator = MemberFixture.create("member", "member@naver.com", Role.USER); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/application/BookmarkCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/application/BookmarkCommandServiceTest.java index 76429f97..1c396822 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/application/BookmarkCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/application/BookmarkCommandServiceTest.java @@ -39,8 +39,8 @@ class BookmarkCommandServiceTest { private TestEntityManager testEntityManager; @Test - @DisplayName("다른 유저의 토픽을 즐겨찾기에 추가할 수 있다.") - public void addTopicInBookmark_Success() { + @DisplayName("다른 회원의 토픽을 즐겨찾기에 추가할 수 있다.") + void addTopicInBookmark_Success() { //given Member creator = MemberFixture.create( "member", @@ -72,8 +72,8 @@ public void addTopicInBookmark_Success() { } @Test - @DisplayName("권한이 없는 다른 유저의 토픽을 즐겨찾기에 추가할 수 없다.") - public void addTopicInBookmark_Fail1() { + @DisplayName("권한이 없는 다른 회원의 토픽을 즐겨찾기에 추가할 수 없다.") + void addTopicInBookmark_Fail1() { //given Member creator = MemberFixture.create( "member", @@ -102,7 +102,7 @@ public void addTopicInBookmark_Fail1() { @Test @DisplayName("즐겨찾기 목록에 있는 토픽을 삭제할 수 있다.") - public void deleteTopicInBookmark_Success() { + void deleteTopicInBookmark_Success() { //given Member creator = MemberFixture.create( "member", @@ -136,7 +136,7 @@ public void deleteTopicInBookmark_Success() { @Test @DisplayName("즐겨찾기 목록에 있는 권한이 없는 토픽은 삭제할 수 없다.") - public void deleteTopicInBookmark_Fail() { + void deleteTopicInBookmark_Fail() { //given Member creator = MemberFixture.create( "member", @@ -167,7 +167,7 @@ public void deleteTopicInBookmark_Fail() { @Test @DisplayName("즐겨찾기 목록에 있는 모든 토픽을 삭제할 수 있다") - public void deleteAllBookmarks_Success() { + void deleteAllBookmarks_Success() { //given Member creatorBefore = memberRepository.save(MemberFixture.create( "member", @@ -197,7 +197,7 @@ public void deleteAllBookmarks_Success() { //then Member creatorAfter = memberRepository.findById(creatorBefore.getId()).get(); - assertThat(creatorAfter.getBookmarks()).hasSize(0); + assertThat(creatorAfter.getBookmarks()).isEmpty(); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/presentation/BookmarkControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/presentation/BookmarkControllerTest.java index ff3e2307..36ff312e 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/bookmark/presentation/BookmarkControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/bookmark/presentation/BookmarkControllerTest.java @@ -19,8 +19,8 @@ class BookmarkControllerTest extends RestDocsIntegration { @Test - @DisplayName("토픽을 유저의 즐겨찾기에 추가") - public void addTopicInBookmark() throws Exception { + @DisplayName("토픽을 회원의 즐겨찾기에 추가") + void addTopicInBookmark() throws Exception { given(bookmarkCommandService.addTopicInBookmark(any(), any())).willReturn(1L); mockMvc.perform( @@ -31,8 +31,8 @@ public void addTopicInBookmark() throws Exception { } @Test - @DisplayName("유저의 토픽 즐겨찾기 목록 삭제") - public void deleteTopicInBookmark() throws Exception { + @DisplayName("회원의 토픽 즐겨찾기 목록 삭제") + void deleteTopicInBookmark() throws Exception { doNothing().when(bookmarkCommandService).deleteTopicInBookmark(any(), any()); mockMvc.perform( @@ -42,4 +42,4 @@ public void deleteTopicInBookmark() throws Exception { ).andDo(restDocs.document()); } -} \ No newline at end of file +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/IntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/common/IntegrationTest.java index 9a91e94a..e20e5caa 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/common/IntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/common/IntegrationTest.java @@ -1,7 +1,7 @@ package com.mapbefine.mapbefine.common; import com.mapbefine.mapbefine.DatabaseCleanup; -import io.restassured.RestAssured; +import io.restassured.*; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/TestAuthHeaderProvider.java b/backend/src/test/java/com/mapbefine/mapbefine/common/TestAuthHeaderProvider.java index 2af99ea5..3a1d8cc9 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/common/TestAuthHeaderProvider.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/common/TestAuthHeaderProvider.java @@ -17,19 +17,23 @@ public TestAuthHeaderProvider(JwtTokenProvider jwtTokenProvider) { public String createAuthHeader(Member member) { Long memberId = member.getId(); - return TOKEN_TYPE + generateToken(memberId); + return TOKEN_TYPE + generateAccessToken(memberId); } public String createAuthHeaderById(Long memberId) { - return TOKEN_TYPE + generateToken(memberId); + return TOKEN_TYPE + generateAccessToken(memberId); } public String createResponseAccessTokenById(Long id) { - return generateToken(id); + return generateAccessToken(id); } - private String generateToken(Long memberId) { - return jwtTokenProvider.createToken(String.valueOf(memberId)); + public String createRefreshToken() { + return jwtTokenProvider.createRefreshToken(); + } + + private String generateAccessToken(Long memberId) { + return jwtTokenProvider.createAccessToken(String.valueOf(memberId)); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/annotation/ServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/common/annotation/ServiceTest.java index 33016bbb..7993e891 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/common/annotation/ServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/common/annotation/ServiceTest.java @@ -1,5 +1,6 @@ package com.mapbefine.mapbefine.common.annotation; +import com.mapbefine.mapbefine.common.config.JpaConfig; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; @@ -7,20 +8,26 @@ import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.context.annotation.ComponentScan.Filter; import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Target(ElementType.TYPE) @Retention(RetentionPolicy.RUNTIME) -@Transactional +@Import(JpaConfig.class) @DataJpaTest( - includeFilters = @Filter( - type = FilterType.ANNOTATION, value = Service.class - ), - excludeFilters = @Filter( - type = FilterType.REGEX, - pattern = "com.mapbefine.mapbefine.oauth.application.*" - ) + includeFilters = { + @Filter(type = FilterType.ANNOTATION, value = Service.class), + }, + excludeFilters = { + @Filter( + type = FilterType.REGEX, + pattern = "com.mapbefine.mapbefine.oauth.application.*" + ), + @Filter( + type = FilterType.REGEX, + pattern = "com.mapbefine.mapbefine.auth.application.*" + ) + } ) public @interface ServiceTest { } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/filter/LatencyRecorderTest.java b/backend/src/test/java/com/mapbefine/mapbefine/common/filter/LatencyRecorderTest.java new file mode 100644 index 00000000..3e19f933 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/common/filter/LatencyRecorderTest.java @@ -0,0 +1,48 @@ +package com.mapbefine.mapbefine.common.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class LatencyRecorderTest { + + private final LatencyRecorder latencyRecorder = new LatencyRecorder(); + + @Test + @DisplayName("실행 시간을 초단위로 측정할 수 있다.") + void getLatencyForSeconds() throws InterruptedException { + //given + latencyRecorder.start(); + + //5초 + Thread.sleep(5000); + + //when + double latencyForSeconds = latencyRecorder.getLatencyForSeconds(); + + //then + assertThat(latencyForSeconds).isGreaterThanOrEqualTo(5) + .isLessThanOrEqualTo(6); + } + + @Test + @DisplayName("다른 쓰레드에 영향을 받지 않는다.") + void getLatencyForSecondsThreadSafe() throws InterruptedException { + //given + Thread thread = new Thread(latencyRecorder::start); + thread.start(); + Thread.sleep(3000); + + latencyRecorder.start(); + Thread.sleep(3000); + + //when + double latencyForSeconds = latencyRecorder.getLatencyForSeconds(); + + //then + assertThat(latencyForSeconds).isGreaterThanOrEqualTo(3) + .isLessThanOrEqualTo(4); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/common/filter/QueryCounterTest.java b/backend/src/test/java/com/mapbefine/mapbefine/common/filter/QueryCounterTest.java new file mode 100644 index 00000000..073af887 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/common/filter/QueryCounterTest.java @@ -0,0 +1,24 @@ +package com.mapbefine.mapbefine.common.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class QueryCounterTest { + + @Test + @DisplayName("값을 증가시킬 수 있다.") + void increase_Success() { + //given + QueryCounter queryCounter = new QueryCounter(); + assertThat(queryCounter.getCount()).isZero(); + + //when + queryCounter.increase(); + + //then + assertThat(queryCounter.getCount()).isOne(); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/image/FileFixture.java b/backend/src/test/java/com/mapbefine/mapbefine/image/FileFixture.java new file mode 100644 index 00000000..47e5edcd --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/image/FileFixture.java @@ -0,0 +1,17 @@ +package com.mapbefine.mapbefine.image; + +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; + +public class FileFixture { + + public static MultipartFile createFile() { + return new MockMultipartFile( + "test", + "test.png", + "img/png", + "image".getBytes() + ); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/image/ImageExtensionTest.java b/backend/src/test/java/com/mapbefine/mapbefine/image/ImageExtensionTest.java new file mode 100644 index 00000000..2f9ed9a0 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/image/ImageExtensionTest.java @@ -0,0 +1,35 @@ +package com.mapbefine.mapbefine.image; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.mapbefine.mapbefine.image.domain.ImageExtension; +import com.mapbefine.mapbefine.image.exception.ImageException.ImageBadRequestException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ImageExtensionTest { + + @DisplayName("fromByImageFileName 를 통해 정상적으로 ImageExtension 을 생성한다.") + @ParameterizedTest + @ValueSource(strings = {"image.jpeg", "image.jpg", "image.jfif", "image.png", "image.svg"}) + void createImageExtensionByFileName_Success(String fileName) { + // given when + String extension = ImageExtension.from(fileName) + .getExtension(); + + // then + assertThat(fileName).contains(extension); + } + + @DisplayName("fromByImageFileName 을 통해 존재하지 않는 확장자라면 예외가 발생한다.") + @ParameterizedTest + @ValueSource(strings = {"image.pppng", "image.jpeeg", "image.gi"}) + void createImageExtensionByFileName_Fail(String fileName) { + // given when then + assertThatThrownBy(() -> ImageExtension.from(fileName)) + .isInstanceOf(ImageBadRequestException.class); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/image/TestImageService.java b/backend/src/test/java/com/mapbefine/mapbefine/image/TestImageService.java new file mode 100644 index 00000000..dd33ef61 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/image/TestImageService.java @@ -0,0 +1,17 @@ +package com.mapbefine.mapbefine.image; + +import com.mapbefine.mapbefine.image.application.ImageService; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +@Service +@Profile("test") +public class TestImageService implements ImageService { + + @Override + public String upload(MultipartFile multipartFile) { + return "https://map-befine-official.github.io/favicon.png"; + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/location/presentation/LocationControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/location/presentation/LocationControllerTest.java index 8ca9b7f4..b7cb901b 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/location/presentation/LocationControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/location/presentation/LocationControllerTest.java @@ -53,7 +53,7 @@ void setUp() { @Test @DisplayName("현재 위치를 기준 토픽의 핀 개수로 나열한다.") - void findNearbyTopicsSortedByPinCount_Success() throws Exception { + void findNearbyTopicsSortedByPinCount() throws Exception { //given double latitude = 37; double longitude = 127; diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/MemberFixture.java b/backend/src/test/java/com/mapbefine/mapbefine/member/MemberFixture.java index d6943a99..6fd7bc14 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/MemberFixture.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/MemberFixture.java @@ -1,10 +1,12 @@ package com.mapbefine.mapbefine.member; import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.auth.domain.member.Admin; import com.mapbefine.mapbefine.auth.domain.member.User; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.OauthId; import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.member.domain.Status; import com.mapbefine.mapbefine.oauth.domain.OauthServerType; import com.mapbefine.mapbefine.topic.domain.Topic; @@ -16,6 +18,7 @@ public static Member create(String name, String email, Role role) { email, "https://map-befine-official.github.io/favicon.png", role, + Status.NORMAL, new OauthId(1L, OauthServerType.KAKAO) ); } @@ -26,11 +29,16 @@ public static Member createWithOauthId(String name, String email, Role role, Oau email, "https://map-befine-official.github.io/favicon.png", role, + Status.NORMAL, oauthId) ; } public static AuthMember createUser(Member member) { + if (member.isAdmin()) { + return new Admin(member.getId()); + } + return new User( member.getId(), member.getCreatedTopics().stream().map(Topic::getId).toList(), diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/MemberIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/MemberIntegrationTest.java index afdbad42..aaf288b2 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/MemberIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/MemberIntegrationTest.java @@ -1,7 +1,7 @@ package com.mapbefine.mapbefine.member; import static com.mapbefine.mapbefine.oauth.domain.OauthServerType.KAKAO; -import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.*; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.Assertions.assertThat; @@ -10,12 +10,12 @@ import com.mapbefine.mapbefine.member.domain.MemberRepository; import com.mapbefine.mapbefine.member.domain.OauthId; import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.member.dto.request.MemberUpdateRequest; import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.member.dto.response.MemberResponse; -import io.restassured.RestAssured; -import io.restassured.common.mapper.TypeRef; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.*; +import io.restassured.common.mapper.*; +import io.restassured.response.*; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -69,7 +69,7 @@ public void setUp() { } @Test - @DisplayName("유저 목록을 조회한다.") + @DisplayName("회원 목록을 조회한다.") void findAllMember() { // given, when ExtractableResponse response = given().log().all() @@ -94,7 +94,7 @@ void findAllMember() { } @Test - @DisplayName("유저를 단일 조회한다.") + @DisplayName("회원을 단일 조회한다.") void findMemberById() { // given, when ExtractableResponse response = given().log().all() @@ -115,7 +115,7 @@ void findMemberById() { } @Test - @DisplayName("로그인 유저가 내 지도 목록을 조회하면, 200을 반환한다.") + @DisplayName("로그인 회원이 내 지도 목록을 조회하면, 200을 반환한다.") void findMyAllTopics_Success() { //when ExtractableResponse response = given().log().all() @@ -131,7 +131,7 @@ void findMyAllTopics_Success() { @Test - @DisplayName("로그인 유저가 내 지도 목록을 조회하면, 200을 반환한다.") + @DisplayName("로그인 회원이 내 지도 목록을 조회하면, 200을 반환한다.") void findMyAllPins_Success() { //when ExtractableResponse response = given().log().all() @@ -147,14 +147,13 @@ void findMyAllPins_Success() { @Test - @DisplayName("유저의 즐겨찾기 토픽 목록을 조회하면, 200을 반환한다.") + @DisplayName("회원의 즐겨찾기 토픽 목록을 조회하면, 200을 반환한다.") void findTopicsInBookmarks_Success() { //when ExtractableResponse response = given().log().all() .header(AUTHORIZATION, creatorAuthHeader) .accept(MediaType.APPLICATION_JSON_VALUE) .when().get("/members/my/bookmarks") - .then().log().all() .extract(); @@ -170,7 +169,6 @@ void findTopicsFromAtlas_Success() { .log().all() .header(HttpHeaders.AUTHORIZATION, creatorAuthHeader) .when().get("/members/my/atlas") - .then().log().all() .extract(); @@ -178,5 +176,21 @@ void findTopicsFromAtlas_Success() { assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); } + @Test + @DisplayName("회원 정보를 정상적으로 수정하면, 200을 반환한다") + void updateMemberInfo_Success() { + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .header(HttpHeaders.AUTHORIZATION, creatorAuthHeader) + .contentType(MediaType.APPLICATION_JSON_VALUE) + .body(new MemberUpdateRequest("new nickname")) + .when().patch("/members/my/profiles") + .then().log().all() + .extract(); + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberCommandServiceTest.java new file mode 100644 index 00000000..388a52b9 --- /dev/null +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberCommandServiceTest.java @@ -0,0 +1,89 @@ +package com.mapbefine.mapbefine.member.application; + +import static java.util.Collections.emptyList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import com.mapbefine.mapbefine.auth.domain.AuthMember; +import com.mapbefine.mapbefine.auth.domain.member.User; +import com.mapbefine.mapbefine.common.annotation.ServiceTest; +import com.mapbefine.mapbefine.member.MemberFixture; +import com.mapbefine.mapbefine.member.domain.Member; +import com.mapbefine.mapbefine.member.domain.MemberInfo; +import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.member.dto.request.MemberUpdateRequest; +import com.mapbefine.mapbefine.member.exception.MemberException.MemberConflictException; +import java.util.NoSuchElementException; +import org.junit.jupiter.api.Assertions; +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; + +@ServiceTest +class MemberCommandServiceTest { + + @Autowired + private MemberCommandService memberCommandService; + @Autowired + private MemberRepository memberRepository; + private Member member; + private AuthMember authMember; + + @BeforeEach + void setUp() { + member = memberRepository.save(MemberFixture.create("member", "member@gmail.com", Role.USER)); + authMember = new User(member.getId(), emptyList(), emptyList()); + } + + @Test + @DisplayName("회원 정보를 수정한다.") + void updateInfoById_Success() { + // given + String expected = "new nickname"; + MemberUpdateRequest request = new MemberUpdateRequest(expected); + + // when + memberCommandService.updateInfoById(authMember, request); + memberRepository.flush(); + + // then + memberRepository.findById(member.getId()) + .map(Member::getMemberInfo) + .ifPresentOrElse( + actual -> { + assertThat(actual).usingRecursiveComparison() + .ignoringFields("nickName") + .isEqualTo(member.getMemberInfo()); + assertThat(actual.getNickName()).isEqualTo(expected); + }, + Assertions::fail + ); + } + + @Test + @DisplayName("존재하지 않는 회원의 정보를 수정하면 예외가 발생한다.") + void updateInfoById_FailByNotExistingMember() { + // given + MemberUpdateRequest request = new MemberUpdateRequest("new nickname"); + User notExistingMember = new User(100L, emptyList(), emptyList()); + + // when, then + assertThatThrownBy(() -> memberCommandService.updateInfoById(notExistingMember, request)) + .isInstanceOf(NoSuchElementException.class); + } + + @Test + @DisplayName("회원 정보 수정 시, 이미 존재하는 닉네임을 설정하면 예외가 발생한다.") + void updateInfoById_FailByDuplicatedNickName() { + // given + MemberInfo memberInfo = member.getMemberInfo(); + MemberUpdateRequest request = new MemberUpdateRequest(memberInfo.getNickName()); + + // when, then + assertThatThrownBy(() -> memberCommandService.updateInfoById(authMember, request)) + .isInstanceOf(MemberConflictException.class); + } + +} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberQueryServiceTest.java index 7152ad0c..2e0d8a4c 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberQueryServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/application/MemberQueryServiceTest.java @@ -52,7 +52,6 @@ class MemberQueryServiceTest { @Autowired private LocationRepository locationRepository; - private AuthMember authMember; private Member member; private List topics; @@ -78,7 +77,7 @@ private void createTopics(Member member) { } @Test - @DisplayName("유저 목록을 조회한다.") + @DisplayName("회원 목록을 조회한다.") void findAllMember() { // given Member member2 = memberRepository.save( @@ -101,7 +100,7 @@ void findAllMember() { } @Test - @DisplayName("유저를 단일 조회한다.") + @DisplayName("회원을 단일 조회한다.") void findMemberById() { // given Member member = memberRepository.save( @@ -117,7 +116,7 @@ void findMemberById() { } @Test - @DisplayName("조회하려는 유저가 없는 경우 예외를 반환한다.") + @DisplayName("조회하려는 회원이 없는 경우 예외를 반환한다.") void findMemberById_whenNoneExists_thenFail() { // given when then assertThatThrownBy(() -> memberQueryService.findById(Long.MAX_VALUE)) @@ -127,7 +126,7 @@ void findMemberById_whenNoneExists_thenFail() { @Test @DisplayName("즐겨찾기 목록에 추가 된 토픽을 조회할 수 있다") - public void findAllTopicsInBookmark_success() { + void findAllTopicsInBookmark_success() { // when List allTopicsInBookmark = memberQueryService.findAllTopicsInBookmark(authMember); @@ -143,7 +142,7 @@ public void findAllTopicsInBookmark_success() { @Test - @DisplayName("멤버 ID를 이용해 모아보기할 모든 Topic들을 가져올 수 있다.") + @DisplayName("회원 ID를 이용해 모아보기할 모든 Topic들을 가져올 수 있다.") void findAtlasByMember_Success() { // when List allTopicsInAtlas = memberQueryService.findAllTopicsInAtlas(authMember); @@ -175,7 +174,7 @@ void findMyAllTopics_Success() { } @Test - @DisplayName("로그인한 유저가 생성한 모든 핀을 가져올 수 있다.") + @DisplayName("로그인한 회원이 생성한 모든 핀을 가져올 수 있다.") void findMyAllPins_Success() { // given Location location = locationRepository.save(LocationFixture.create()); @@ -199,4 +198,5 @@ void findMyAllPins_Success() { .isEqualTo(pinIds); } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberInfoTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberInfoTest.java index 7c4d4a20..d1907931 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberInfoTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberInfoTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.SoftAssertions.assertSoftly; import com.mapbefine.mapbefine.common.exception.BadRequestException.ImageBadRequestException; import com.mapbefine.mapbefine.member.exception.MemberException.MemberBadRequestException; @@ -22,16 +23,18 @@ class Validate { private final String VALID_EMAIL = "member@naver.com"; private final String VALID_IMAGE_URL = "https://map-befine-official.github.io/favicon.png"; private final Role VALID_ROLE = Role.ADMIN; + private final Status VALID_STATUS = Status.NORMAL; @Test - @DisplayName("정확한 값을 입력하면 객체가 생성된다") - void success() { + @DisplayName("유효한 정보를 입력했을 때 객체를 생성할 수 있다.") + void create_Success() { //given when MemberInfo memberInfo = MemberInfo.of( VALID_NICK_NAME, VALID_EMAIL, VALID_IMAGE_URL, - VALID_ROLE + VALID_ROLE, + VALID_STATUS ); //then @@ -42,17 +45,42 @@ void success() { assertThat(memberInfo.getRole()).isEqualTo(VALID_ROLE); } + @Test + @DisplayName("일부 정보(닉네임)만 변경한 회원 정보를 생성할 수 있다.") + void createUpdatedMemberInfo() { + // given + MemberInfo before = MemberInfo.of( + "member", + "member@naver.com", + "https://map-befine-official.github.io/favicon.png", + Role.ADMIN, + Status.NORMAL); + + // when + String expected = "newNickName"; + MemberInfo patched = before.createUpdatedMemberInfo(expected); + + // then + assertSoftly(softly -> { + assertThat(patched).usingRecursiveComparison() + .ignoringFields("nickName") + .isEqualTo(before); + assertThat(patched.getNickName()).isEqualTo(expected); + }); + } + @ParameterizedTest @NullSource @ValueSource(strings = {"", "aaaaaaaaaaaaaaaaaaaaa"}) - @DisplayName("유효한 이름이 아닌 경우 예외가 발생한다") - void whenNameIsInvalid_thenFail(String invalidNickName) { + @DisplayName("유효한 닉네임이 아닌 경우 예외가 발생한다") + void validateNickName(String invalidNickName) { //given when then assertThatThrownBy(() -> MemberInfo.of( invalidNickName, VALID_EMAIL, VALID_IMAGE_URL, - VALID_ROLE + VALID_ROLE, + VALID_STATUS )).isInstanceOf(MemberBadRequestException.class); } @@ -61,19 +89,20 @@ void whenNameIsInvalid_thenFail(String invalidNickName) { @EmptySource @ValueSource(strings = "member") @DisplayName("유효한 이메일이 아닌 경우 예외가 발생한다") - void whenEmailIsInvalid_thenFail(String invalidEmail) { + void validateEmail(String invalidEmail) { //given when then assertThatThrownBy(() -> MemberInfo.of( VALID_NICK_NAME, invalidEmail, VALID_IMAGE_URL, - VALID_ROLE + VALID_ROLE, + VALID_STATUS )).isInstanceOf(MemberBadRequestException.class); } @Test @DisplayName("올바르지 않은 형식의 Image Url 이 들어오는 경우 예외가 발생한다.") - void whenImageUrlIsInvalid_thenFail() { + void validateImageUrl() { String invalidImageUrl = "image.png"; //given when then @@ -81,19 +110,21 @@ void whenImageUrlIsInvalid_thenFail() { VALID_NICK_NAME, VALID_EMAIL, invalidImageUrl, - VALID_ROLE + VALID_ROLE, + VALID_STATUS )).isInstanceOf(ImageBadRequestException.class); } @Test @DisplayName("유효하지 않은 Role 이 들어오는 경우 예외가 발생한다.") - void whenRoleIsInvalid_thenFail() { + void validateRole() { //given when then assertThatThrownBy(() -> MemberInfo.of( VALID_NICK_NAME, VALID_EMAIL, VALID_IMAGE_URL, null + ,VALID_STATUS )).isInstanceOf(IllegalArgumentException.class); } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberTest.java index 0ee9b697..7fcb4b10 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/domain/MemberTest.java @@ -16,6 +16,7 @@ void createMember_success() { String email = "member@naver.com"; String imageUrl = "https://map-befine-official.github.io/favicon.png"; Role role = Role.ADMIN; + Status status = Status.NORMAL; // when Member member = Member.of( @@ -23,7 +24,9 @@ void createMember_success() { email, imageUrl, role, - new OauthId(1L, OauthServerType.KAKAO)); + status, + new OauthId(1L, OauthServerType.KAKAO) + ); // then assertThat(member).isNotNull(); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/member/presentation/MemberControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/member/presentation/MemberControllerTest.java index 7efbf8c3..df12531d 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/member/presentation/MemberControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/member/presentation/MemberControllerTest.java @@ -7,6 +7,7 @@ import com.mapbefine.mapbefine.common.RestDocsIntegration; import com.mapbefine.mapbefine.location.LocationFixture; import com.mapbefine.mapbefine.member.application.MemberQueryService; +import com.mapbefine.mapbefine.member.dto.request.MemberUpdateRequest; import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.member.dto.response.MemberResponse; import com.mapbefine.mapbefine.pin.dto.response.PinResponse; @@ -16,6 +17,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; class MemberControllerTest extends RestDocsIntegration { @@ -24,7 +26,7 @@ class MemberControllerTest extends RestDocsIntegration { private MemberQueryService memberQueryService; @Test - @DisplayName("유저 목록 조회") + @DisplayName("회원 목록 조회") void findAllMember() throws Exception { List memberResponses = List.of( new MemberResponse( @@ -48,7 +50,7 @@ void findAllMember() throws Exception { } @Test - @DisplayName("유저 단일 조회") + @DisplayName("회원 단일 조회") void findMemberById() throws Exception { MemberDetailResponse memberDetailResponse = new MemberDetailResponse( 1L, @@ -67,7 +69,7 @@ void findMemberById() throws Exception { } @Test - @DisplayName("유저의 모아보기 목록 조회") + @DisplayName("회원의 모아보기 목록 조회") void findAllTopicsInAtlas() throws Exception { List responses = List.of( new TopicResponse( @@ -102,7 +104,7 @@ void findAllTopicsInAtlas() throws Exception { } @Test - @DisplayName("유저의 즐겨찾기 목록 조회") + @DisplayName("회원의 즐겨찾기 목록 조회") void findAllTopicsInBookmark() throws Exception { List responses = List.of( new TopicResponse( @@ -137,7 +139,7 @@ void findAllTopicsInBookmark() throws Exception { } @Test - @DisplayName("유저의 지도 목록 조회") + @DisplayName("회원의 지도 목록 조회") void findMyAllTopics() throws Exception { List responses = List.of( new TopicResponse( @@ -172,7 +174,7 @@ void findMyAllTopics() throws Exception { } @Test - @DisplayName("유저의 핀 목록 조회") + @DisplayName("회원의 핀 목록 조회") void findMyAllPins() throws Exception { List responses = List.of( new PinResponse( @@ -202,4 +204,17 @@ void findMyAllPins() throws Exception { ).andDo(restDocs.document()); } + @Test + @DisplayName("회원의 정보 수정") + void updateMyInfo() throws Exception { + MemberUpdateRequest request = new MemberUpdateRequest("새로운 닉네임"); + + mockMvc.perform( + MockMvcRequestBuilders.patch("/members/my/profiles") + .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + ).andDo(restDocs.document()); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/oauth/application/OauthServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/oauth/application/OauthServiceTest.java index 12d143ee..4f23f219 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/oauth/application/OauthServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/oauth/application/OauthServiceTest.java @@ -8,13 +8,13 @@ import com.mapbefine.mapbefine.DatabaseCleanup; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; +import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.oauth.domain.OauthMember; import com.mapbefine.mapbefine.oauth.domain.OauthServerType; import com.mapbefine.mapbefine.oauth.domain.kakao.KakaoApiClient; import com.mapbefine.mapbefine.oauth.domain.kakao.KakaoOauthProperties; import com.mapbefine.mapbefine.oauth.domain.kakao.dto.KakaoMemberResponse; import com.mapbefine.mapbefine.oauth.domain.kakao.dto.KakaoToken; -import com.mapbefine.mapbefine.oauth.dto.LoginInfoResponse; import java.util.NoSuchElementException; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -60,13 +60,13 @@ class OauthServiceTest { @BeforeEach void setUp() { given(kakaoOauthProperties.clientId()).willReturn("774"); - given(kakaoOauthProperties.scope()).willReturn(new String[] {"profile_nickname", "account_email"}); + given(kakaoOauthProperties.scope()).willReturn(new String[]{"profile_nickname", "account_email"}); given(kakaoOauthProperties.redirectUri()).willReturn("http://localhost:3000/oauth/redirected/kakao"); given(kakaoOauthProperties.clientSecret()).willReturn("4521"); - + given(kakaoToken.tokenType()).willReturn("tokenType"); given(kakaoToken.accessToken()).willReturn("accessToken"); - + given(kakaoMemberResponse.extract()).willReturn(oauthMember); given(kakaoApiClient.fetchToken(any())).willReturn(kakaoToken); @@ -83,7 +83,6 @@ void tearDown() { void getAuthCodeRequestUrl_success() { // when String url = oauthService.getAuthCodeRequestUrl(OauthServerType.KAKAO); - System.out.println(oauthMember.toRegisterMember().getMemberInfo().getNickName()); // then assertThat(url).isEqualTo("https://kauth.kakao.com/oauth/authorize?" @@ -92,37 +91,36 @@ void getAuthCodeRequestUrl_success() { + "&redirect_uri=" + kakaoOauthProperties.redirectUri() + "&scope=" + String.join(",", kakaoOauthProperties.scope())); } - + @Test @DisplayName("Kakao 로 로그인 처음 하는 경우 사용자의 정보를 저장하고 사용자의 로그인 정보를 반환한다.") void loginAndRegister_success() { // when - LoginInfoResponse response = oauthService.login(KAKAO, "auth"); - Member savedMember = memberRepository.findByMemberInfoEmail("yshert@naver.com") + MemberDetailResponse memberDetailResponse = oauthService.login(KAKAO, "auth"); + Member savedMember = memberRepository.findById(memberDetailResponse.id()) .orElseThrow(NoSuchElementException::new); // then - assertThat(response.accessToken()).isNotNull(); - assertThat(response.member().email()).isEqualTo("yshert@naver.com"); - assertThat(response.member().imageUrl()).isEqualTo( + assertThat(memberDetailResponse.email()).isEqualTo("yshert@naver.com"); + assertThat(memberDetailResponse.imageUrl()).isEqualTo( "https://map-befine-official.github.io/favicon.png" ); - assertThat(savedMember.getId()).isEqualTo(response.member().id()); + assertThat(savedMember.getId()).isEqualTo(memberDetailResponse.id()); } @Test @DisplayName("Kakao 로 로그인 처음이 아닌 경우 사용자의 정보를 저장하지 않고 사용자의 로그인 정보를 반환한다.") void login() { // given - LoginInfoResponse firstLogin = oauthService.login(KAKAO, "auth"); - Member savedMember = memberRepository.findByMemberInfoEmail(firstLogin.member().email()) + MemberDetailResponse firstLogin = oauthService.login(KAKAO, "auth"); + Member savedMember = memberRepository.findById(firstLogin.id()) .orElseThrow(NoSuchElementException::new); // when - LoginInfoResponse secondLogin = oauthService.login(KAKAO, "auth"); + MemberDetailResponse secondLogin = oauthService.login(KAKAO, "auth"); // then - assertThat(savedMember.getId()).isEqualTo(secondLogin.member().id()); + assertThat(savedMember.getId()).isEqualTo(secondLogin.id()); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberTest.java b/backend/src/test/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberTest.java index 06e29413..beb6c1b8 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/oauth/domain/OauthMemberTest.java @@ -22,7 +22,6 @@ void toRegisterMember() { .getMemberInfo() .getNickName(); - System.out.println(expected); assertThat(expected).contains("모험가"); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/oauth/presentation/OauthControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/oauth/presentation/OauthControllerTest.java index 0ed9e5d1..e69de29b 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/oauth/presentation/OauthControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/oauth/presentation/OauthControllerTest.java @@ -1,63 +0,0 @@ -package com.mapbefine.mapbefine.oauth.presentation; - -import static com.mapbefine.mapbefine.oauth.domain.OauthServerType.KAKAO; -import static org.mockito.BDDMockito.given; - -import com.mapbefine.mapbefine.common.RestDocsIntegration; -import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; -import com.mapbefine.mapbefine.oauth.application.OauthService; -import com.mapbefine.mapbefine.oauth.dto.LoginInfoResponse; -import java.time.LocalDateTime; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; - -class OauthControllerTest extends RestDocsIntegration { - - @MockBean - private OauthService oauthService; - - @Test - @DisplayName("kakao 소셜 로그인 Redirection Url 반환") - void redirection() throws Exception { - // given - given(oauthService.getAuthCodeRequestUrl(KAKAO)).willReturn( - "https://kauth.kakao.com/oauth/authorize?" - + "response_type=code" - + "&client_id={client_id}" - + "&redirect_uri={redirection_uri}" - + "&scope={scope}" - ); - - // when then - mockMvc.perform( - MockMvcRequestBuilders.get("/oauth/kakao") - ).andDo(restDocs.document()); - } - - @Test - @DisplayName("소셜 로그인 성공시 로그인한 유저 정보 반환") - void login() throws Exception { - // given - String code = "auth_code"; - LoginInfoResponse response = new LoginInfoResponse( - testAuthHeaderProvider.createResponseAccessTokenById(Long.MAX_VALUE), - new MemberDetailResponse( - Long.MAX_VALUE, - "모험가03fcb0d", - "yshert@naver.com", - "https://map-befine-official.github.io/favicon.png", - LocalDateTime.now() - ) - ); - given(oauthService.login(KAKAO, code)).willReturn(response); - - // when then - mockMvc.perform( - MockMvcRequestBuilders.get("/oauth/login/kakao") - .param("code", code) - ).andDo(restDocs.document()); - } - -} diff --git a/backend/src/test/java/com/mapbefine/mapbefine/permission/PermissionIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/permission/PermissionIntegrationTest.java index 983dd8e0..2a663ea8 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/permission/PermissionIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/permission/PermissionIntegrationTest.java @@ -1,7 +1,7 @@ package com.mapbefine.mapbefine.permission; import static com.mapbefine.mapbefine.oauth.domain.OauthServerType.KAKAO; -import static io.restassured.RestAssured.given; +import static io.restassured.RestAssured.*; import static org.apache.http.HttpHeaders.AUTHORIZATION; import static org.assertj.core.api.Assertions.assertThat; @@ -16,14 +16,14 @@ import com.mapbefine.mapbefine.permission.domain.Permission; import com.mapbefine.mapbefine.permission.domain.PermissionRepository; import com.mapbefine.mapbefine.permission.dto.request.PermissionRequest; -import com.mapbefine.mapbefine.permission.dto.response.PermissionDetailResponse; -import com.mapbefine.mapbefine.permission.dto.response.PermissionResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionMemberDetailResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionedMemberResponse; +import com.mapbefine.mapbefine.permission.dto.response.TopicAccessDetailResponse; import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import io.restassured.common.mapper.TypeRef; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.common.mapper.*; +import io.restassured.response.*; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -33,7 +33,7 @@ import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -public class PermissionIntegrationTest extends IntegrationTest { +class PermissionIntegrationTest extends IntegrationTest { @Autowired private MemberRepository memberRepository; @@ -79,7 +79,7 @@ public void setUp() { } @Test - @DisplayName("Topic 을 만든자가 특정 유저에게 권한을 준다.") + @DisplayName("Topic 을 만든 회원이 특정 회원에게 해당 Topic 에 대한 권한을 준다.") void addPermission() { // given Topic topic = topicRepository.save(TopicFixture.createByName("topicName", creator)); @@ -100,7 +100,7 @@ void addPermission() { } @Test - @DisplayName("Topic 을 만든자가 특정 유저에게 권한을 삭제한다.") + @DisplayName("Topic 을 만든 회원이 특정 회원이 가진 해당 Topic 에 대한 권한을 삭제한다.") void deletePermission() { // given Topic topic = topicRepository.save(TopicFixture.createByName("topicName", creator)); @@ -121,8 +121,8 @@ void deletePermission() { } @Test - @DisplayName("Topic 에 권한을 가진 자들을 모두 조회한다.") - void findMemberTopicPermissionAll() { + @DisplayName("Topic 의 접근 정보(권한 회원 목록 및 공개 여부)를 조회한다.") + void findTopicAccessDetailById() { // given Topic topic = topicRepository.save(TopicFixture.createByName("topicName", creator)); Permission permission1 = @@ -138,22 +138,22 @@ void findMemberTopicPermissionAll() { .when().get("/permissions/topics/" + topic.getId()) .then().log().all() .extract(); + TopicAccessDetailResponse actual = response.as(new TypeRef<>() {}); // then - List permissionResponse = response.as(new TypeRef<>() { - }); assertThat(response.statusCode()) .isEqualTo(HttpStatus.OK.value()); - assertThat(permissionResponse) + assertThat(actual.publicity()).isEqualTo(topic.getPublicity()); + assertThat(actual.permissionedMembers()) .hasSize(2) - .extracting(PermissionResponse::memberResponse) + .extracting(PermissionedMemberResponse::memberResponse) .usingRecursiveComparison() .isEqualTo(List.of(MemberResponse.from(user1), MemberResponse.from(user2))); } @Test @DisplayName("Topic 에 권한을 가진 자를 조회한다.") - void findMemberTopicPermissionById() { + void findPermissionById() { // given Topic topic = topicRepository.save(TopicFixture.createByName("topicName", creator)); Permission permission = @@ -166,14 +166,13 @@ void findMemberTopicPermissionById() { .when().get("/permissions/" + permission.getId()) .then().log().all() .extract(); + PermissionMemberDetailResponse actual = response.as(new TypeRef<>() {}); // then - PermissionDetailResponse permissionDetailResponse = response.as( - PermissionDetailResponse.class); assertThat(response.statusCode()) .isEqualTo(HttpStatus.OK.value()); - assertThat(permissionDetailResponse) - .extracting(PermissionDetailResponse::memberDetailResponse) + assertThat(actual) + .extracting(PermissionMemberDetailResponse::memberDetailResponse) .usingRecursiveComparison() .ignoringFieldsOfTypes(LocalDateTime.class) .isEqualTo(MemberDetailResponse.from(user1)); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionCommandServiceTest.java index 9fe7ec61..73a86dc3 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionCommandServiceTest.java @@ -99,7 +99,7 @@ void saveMemberTopicPermissionByCreator() { } @Test - @DisplayName("Creator 가 아닌 유저가 권한을 주려는 경우 예외가 발생한다.") + @DisplayName("Creator 가 아닌 회원이 권한을 주려는 경우 예외가 발생한다.") void saveMemberTopicPermissionByUser() { // given Member creator = memberRepository.save( @@ -127,7 +127,7 @@ void saveMemberTopicPermissionByUser() { } @Test - @DisplayName("Guest 가 유저에게 권한을 주려는 경우 예외가 발생한다.") + @DisplayName("Guest 가 회원에게 권한을 주려는 경우 예외가 발생한다.") void saveMemberTopicPermissionByGuest() { // given Member creator = memberRepository.save( @@ -262,7 +262,7 @@ void deleteMemberTopicPermissionByCreator() { } @Test - @DisplayName("creator 가 아닌 유저가 권한을 삭제하는 경우 예외가 발생한다.") + @DisplayName("creator 가 아닌 회원이 권한을 삭제하는 경우 예외가 발생한다.") void deleteMemberTopicPermissionByUser() { // given Member creator = memberRepository.save( @@ -289,7 +289,7 @@ void deleteMemberTopicPermissionByUser() { } @Test - @DisplayName("존재하지 않는 유저의 권한을 삭제하려 하려는 경우 예외가 발생한다.") + @DisplayName("존재하지 않는 회원의 권한을 삭제하려 하려는 경우 예외가 발생한다.") void deleteMemberTopicPermissionByCreator_whenNoneExistsPermission_thenFail() { // given Member creator = memberRepository.save( diff --git a/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionQueryServiceTest.java index def49d5f..43afe105 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionQueryServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/permission/application/PermissionQueryServiceTest.java @@ -12,8 +12,9 @@ import com.mapbefine.mapbefine.member.dto.response.MemberResponse; import com.mapbefine.mapbefine.permission.domain.Permission; import com.mapbefine.mapbefine.permission.domain.PermissionRepository; -import com.mapbefine.mapbefine.permission.dto.response.PermissionDetailResponse; -import com.mapbefine.mapbefine.permission.dto.response.PermissionResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionMemberDetailResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionedMemberResponse; +import com.mapbefine.mapbefine.permission.dto.response.TopicAccessDetailResponse; import com.mapbefine.mapbefine.permission.exception.PermissionException.PermissionNotFoundException; import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; @@ -36,9 +37,9 @@ class PermissionQueryServiceTest { private PermissionQueryService permissionQueryService; @Test - @DisplayName("Topic 에 권한이 있는자들을 모두 조회한다.") - // creator 는 권한이 있는자들을 조회할 때 조회되어야 할 것인가?? - void findAllWithPermission() { + @DisplayName("특정 지도에 대한 접근 정보(권한 회원 목록 및 공개 여부)를 모두 조회한다. 권한 회원 중 생성자는 제외한다.") + void findTopicAccessDetailById() { + /// TODO: 2023/09/15 리팩터링 // given Member member1InTopic1 = memberRepository.save( MemberFixture.create("member", "member@naver.com", Role.USER) @@ -62,20 +63,26 @@ void findAllWithPermission() { ); // when - List permissionRespons = permissionQueryService.findAllTopicPermissions(topic1.getId()); - MemberResponse memberResponse1 = MemberResponse.from(member1InTopic1); - MemberResponse memberResponse2 = MemberResponse.from(member2InTopic1); + TopicAccessDetailResponse accessDetailResponse = permissionQueryService.findTopicAccessDetailById(topic1.getId()); + MemberResponse member1Response = MemberResponse.from(member1InTopic1); + MemberResponse member2Response = MemberResponse.from(member2InTopic1); // then - assertThat(permissionRespons).hasSize(2) - .extracting(PermissionResponse::memberResponse) + assertThat(accessDetailResponse.publicity()).isEqualTo(topic1.getPublicity()); + List permissionedMembers = accessDetailResponse.permissionedMembers(); + assertThat(permissionedMembers).hasSize(2) + .extracting(PermissionedMemberResponse::memberResponse) .usingRecursiveComparison() - .isEqualTo(List.of(memberResponse1, memberResponse2)); + .isEqualTo(List.of(member1Response, member2Response)); + assertThat(permissionedMembers) + .extracting(PermissionedMemberResponse::memberResponse) + .map(MemberResponse::id) + .doesNotContain(topic1.getCreator().getId()); } @Test @DisplayName("ID 를 통해서 토픽에 권한이 있는자를 조회한다.") - void findMemberTopicPermissionById() { + void findPermissionById() { // given Member creator = memberRepository.save( MemberFixture.create("member", "member@naver.com", Role.USER) @@ -89,20 +96,20 @@ void findMemberTopicPermissionById() { ).getId(); // when - PermissionDetailResponse permissionDetailResponse = + PermissionMemberDetailResponse permissionMemberDetailResponse = permissionQueryService.findPermissionById(savedId); MemberDetailResponse permissionUserResponse = MemberDetailResponse.from(permissionUser); // then - assertThat(permissionDetailResponse) - .extracting(PermissionDetailResponse::memberDetailResponse) + assertThat(permissionMemberDetailResponse) + .extracting(PermissionMemberDetailResponse::memberDetailResponse) .usingRecursiveComparison() .isEqualTo(permissionUserResponse); } @Test @DisplayName("ID 를 통해서 토픽에 권한이 있는자를 조회하려 할 때, 결과가 존재하지 않을 때 예외가 발생한다.") - void findMemberTopicPermissionById_whenNoneExistsPermission_thenFail() { + void findPermissionById_whenNoneExistsPermission_thenFail() { // given when then assertThatThrownBy(() -> permissionQueryService.findPermissionById(Long.MAX_VALUE)) .isInstanceOf(PermissionNotFoundException.class); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/permission/presentation/PermissionControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/permission/presentation/PermissionControllerTest.java index e6885267..e2311f47 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/permission/presentation/PermissionControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/permission/presentation/PermissionControllerTest.java @@ -5,16 +5,14 @@ import static org.mockito.BDDMockito.given; import com.mapbefine.mapbefine.common.RestDocsIntegration; -import com.mapbefine.mapbefine.member.MemberFixture; -import com.mapbefine.mapbefine.member.domain.Member; -import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.member.dto.response.MemberDetailResponse; import com.mapbefine.mapbefine.member.dto.response.MemberResponse; -import com.mapbefine.mapbefine.permission.application.PermissionCommandService; import com.mapbefine.mapbefine.permission.application.PermissionQueryService; import com.mapbefine.mapbefine.permission.dto.request.PermissionRequest; -import com.mapbefine.mapbefine.permission.dto.response.PermissionDetailResponse; -import com.mapbefine.mapbefine.permission.dto.response.PermissionResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionMemberDetailResponse; +import com.mapbefine.mapbefine.permission.dto.response.PermissionedMemberResponse; +import com.mapbefine.mapbefine.permission.dto.response.TopicAccessDetailResponse; +import com.mapbefine.mapbefine.topic.domain.Publicity; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -27,13 +25,10 @@ class PermissionControllerTest extends RestDocsIntegration { @MockBean private PermissionQueryService permissionQueryService; - @MockBean - private PermissionCommandService permissionCommandService; @Test @DisplayName("권한 추가") void addPermission() throws Exception { - Member member = MemberFixture.create("member", "member@naver.com", Role.ADMIN); PermissionRequest request = new PermissionRequest(1L, List.of(1L, 2L, 3L)); mockMvc.perform( @@ -47,8 +42,6 @@ void addPermission() throws Exception { @Test @DisplayName("권한 삭제") void deletePermission() throws Exception { - Member member = MemberFixture.create("member", "member@naver.com", Role.ADMIN); - mockMvc.perform( MockMvcRequestBuilders.delete("/permissions/1") .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) @@ -56,28 +49,15 @@ void deletePermission() throws Exception { } @Test - @DisplayName("권한이 있는 자들 모두 조회") - void findAllTopicPermissions() throws Exception { - List permissionResponses = List.of( - new PermissionResponse( - 1L, - new MemberResponse( - 1L, - "member", - "member@naver.com" - ) - ), - new PermissionResponse( - 1L, - new MemberResponse( - 2L, - "memberr", - "memberr@naver.com" - ) - ) + @DisplayName("특정 토픽 접근 정보 조회(권한 회원 목록, 공개 여부)") + void findTopicAccessDetailByTopicId() throws Exception { + List permissionedMembers = List.of( + new PermissionedMemberResponse(1L, new MemberResponse(1L, "member", "member@naver.com")), + new PermissionedMemberResponse(1L, new MemberResponse(2L, "memberr", "memberr@naver.com")) ); + TopicAccessDetailResponse response = new TopicAccessDetailResponse(Publicity.PUBLIC, permissionedMembers); - given(permissionQueryService.findAllTopicPermissions(any())).willReturn(permissionResponses); + given(permissionQueryService.findTopicAccessDetailById(any())).willReturn(response); mockMvc.perform( MockMvcRequestBuilders.get("/permissions/topics/1") @@ -88,7 +68,7 @@ void findAllTopicPermissions() throws Exception { @Test @DisplayName("권한이 있는 자들 모두 조회") void findPermissionById() throws Exception { - PermissionDetailResponse permissionDetailResponse = new PermissionDetailResponse( + PermissionMemberDetailResponse permissionMemberDetailResponse = new PermissionMemberDetailResponse( 1L, LocalDateTime.now(), new MemberDetailResponse( @@ -100,7 +80,7 @@ void findPermissionById() throws Exception { ) ); - given(permissionQueryService.findPermissionById(any())).willReturn(permissionDetailResponse); + given(permissionQueryService.findPermissionById(any())).willReturn(permissionMemberDetailResponse); mockMvc.perform( MockMvcRequestBuilders.get("/permissions/1") diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/PinIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/PinIntegrationTest.java index 0949a5cc..031f0da8 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/PinIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/PinIntegrationTest.java @@ -12,15 +12,15 @@ import com.mapbefine.mapbefine.member.domain.MemberRepository; import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.pin.dto.request.PinCreateRequest; -import com.mapbefine.mapbefine.pin.dto.request.PinImageCreateRequest; +import com.mapbefine.mapbefine.pin.dto.response.PinDetailResponse; import com.mapbefine.mapbefine.pin.dto.response.PinImageResponse; import com.mapbefine.mapbefine.pin.dto.response.PinResponse; import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import io.restassured.RestAssured; -import io.restassured.response.ExtractableResponse; -import io.restassured.response.Response; +import io.restassured.*; +import io.restassured.response.*; +import java.io.File; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -31,8 +31,6 @@ class PinIntegrationTest extends IntegrationTest { - private static final String BASE_IMAGE = "https://map-befine-official.github.io/favicon.png"; - private Topic topic; private Location location; private Member member; @@ -72,7 +70,7 @@ void saveTopicAndLocation() { topic.getId(), "pin", "description", - "기존에 없는 주소", + "address", "legalDongCode", 37, 126 @@ -81,7 +79,7 @@ void saveTopicAndLocation() { topic.getId(), "pine2", "description", - "기존에 없는 주소", + "address", "legalDongCode", 37.12345, 126.12345 @@ -100,15 +98,39 @@ void addIfExistDuplicateLocation_Success() { } private ExtractableResponse createPin(PinCreateRequest request) { - return RestAssured.given().log().all() + String imageFilePath = getClass().getClassLoader() + .getResource("test.png") + .getPath(); + + return RestAssured.given() + .log().all() .header(AUTHORIZATION, authHeader) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(request) + .multiPart("images", new File(imageFilePath), MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("images", new File(imageFilePath), MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("images", new File(imageFilePath), MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("request", request, MediaType.APPLICATION_JSON_VALUE) .when().post("/pins") .then().log().all() .extract(); } + @Test + @DisplayName("Image List 없이 Pin 을 정상적으로 생성한다.") + void addIfNonExistImageList_Success() { + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .header(AUTHORIZATION, authHeader) + .multiPart("request", createRequestDuplicateLocation, MediaType.APPLICATION_JSON_VALUE) + .when().post("/pins") + .then().log().all() + .extract(); + + // then + assertThat(response.header("Location")).isNotBlank(); + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + } + @Test @DisplayName("Pin을 생성하면 저장된 Pin의 Location 헤더값과 201을 반환한다.") void addIfNotExistDuplicateLocation_Success() { @@ -154,6 +176,8 @@ void findDetail_Success() { // when ExtractableResponse response = findById(pinId); + PinDetailResponse as = response.as(PinDetailResponse.class); + // then assertThat(response.jsonPath().getString("name")) .isEqualTo(createRequestNoDuplicateLocation.name()); @@ -192,10 +216,15 @@ void addImage_Success() { } private ExtractableResponse createPinImage(long pinId) { + String imageFilePath = getClass().getClassLoader() + .getResource("test.png") + .getPath(); + File mockFile = new File(imageFilePath); + return RestAssured.given().log().all() .header(AUTHORIZATION, authHeader) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(new PinImageCreateRequest(pinId, BASE_IMAGE)) + .multiPart("image", mockFile, MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("pinId", pinId, MediaType.APPLICATION_JSON_VALUE) .when().post("/pins/images") .then().log().all() .extract(); @@ -226,7 +255,7 @@ void removeImage_Success() { @Test - @DisplayName("멤버별 Pin 목록을 조회하면 200을 반환한다") + @DisplayName("회원별 Pin 목록을 조회하면 200을 반환한다") void findAllPinsByMemberId_Success() { //given createPinAndGetId(createRequestDuplicateLocation); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinCommandServiceTest.java index ef41b89f..020dc74f 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinCommandServiceTest.java @@ -7,6 +7,8 @@ import com.mapbefine.mapbefine.auth.domain.member.Admin; import com.mapbefine.mapbefine.auth.domain.member.Guest; import com.mapbefine.mapbefine.common.annotation.ServiceTest; +import com.mapbefine.mapbefine.image.FileFixture; +import com.mapbefine.mapbefine.image.exception.ImageException.ImageBadRequestException; import com.mapbefine.mapbefine.location.LocationFixture; import com.mapbefine.mapbefine.location.domain.Location; import com.mapbefine.mapbefine.location.domain.LocationRepository; @@ -33,11 +35,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.multipart.MultipartFile; @ServiceTest class PinCommandServiceTest { - private static final String BASE_IMAGE = "https://map-befine-official.github.io/favicon.png"; + private static final MultipartFile BASE_IMAGE_FILE = FileFixture.createFile(); + private static final String BASE_IMAGE = "https://mapbefine.github.io/favicon.png"; @Autowired private PinCommandService pinCommandService; @@ -82,7 +86,7 @@ void setUp() { @DisplayName("핀을 저장하려는 위치(Location)가 존재하면 해당 위치에 핀을 저장한다.") void saveIfExistLocation_Success() { // given, when - long savedPinId = pinCommandService.save(authMember, createRequest); + long savedPinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); PinDetailResponse actual = pinQueryService.findDetailById(authMember, savedPinId); Pin pin = pinRepository.findById(savedPinId).get(); Location savedLocation = pin.getLocation(); @@ -91,7 +95,7 @@ void saveIfExistLocation_Success() { assertThat(savedLocation.getId()).isEqualTo(location.getId()); assertThat(actual).usingRecursiveComparison() .ignoringFields("updatedAt") - .isEqualTo(PinDetailResponse.from(pin)); + .isEqualTo(PinDetailResponse.of(pin, Boolean.TRUE)); } @Test @@ -112,7 +116,7 @@ void saveIfNotExistLocation_Success() { ); // when - long savedPinId = pinCommandService.save(authMember, createRequest); + long savedPinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); PinDetailResponse actual = pinQueryService.findDetailById(authMember, savedPinId); Pin pin = pinRepository.findById(savedPinId).get(); Location savedLocation = pin.getLocation(); @@ -121,7 +125,7 @@ void saveIfNotExistLocation_Success() { assertThat(savedLocation.getId()).isNotEqualTo(location.getId()); assertThat(actual).usingRecursiveComparison() .ignoringFields("updatedAt") - .isEqualTo(PinDetailResponse.from(pin)); + .isEqualTo(PinDetailResponse.of(pin, Boolean.TRUE)); List locations = locationRepository.findAll(); assertThat(locations).hasSize(2); @@ -130,30 +134,64 @@ void saveIfNotExistLocation_Success() { .containsExactly(location.getId(), savedLocation.getId()); } + @Test + @DisplayName("핀을 추가하면 토픽에 핀의 변경 일시를 새로 반영한다. (모든 일시는 영속화 시점 기준이다.)") + void save_Success_UpdateLastPinAddedAt() { + // given when + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); + Pin pin = pinRepository.findById(pinId) + .orElseGet(Assertions::fail); + + // then + topicRepository.findById(createRequest.topicId()) + .ifPresentOrElse( + topic -> assertThat(topic.getLastPinUpdatedAt()).isEqualTo(pin.getCreatedAt()), + Assertions::fail + ); + } + @Test @DisplayName("권한이 없는 토픽에 핀을 저장하면 예외를 발생시킨다.") void save_FailByForbidden() { - - assertThatThrownBy(() -> pinCommandService.save(new Guest(), createRequest)) + assertThatThrownBy(() -> pinCommandService.save(new Guest(), List.of(BASE_IMAGE_FILE), createRequest)) .isInstanceOf(PinForbiddenException.class); } + + @Test + @DisplayName("핀을 변경하면 토픽에 핀의 변경 일시를 새로 반영한다. (모든 일시는 영속화 시점 기준이다.)") + void update_Success_UpdateLastPinsAddedAt() { + // given + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); + + // when + pinCommandService.update(authMember, pinId, new PinUpdateRequest("name", "update")); + Pin pin = pinRepository.findById(pinId) + .orElseGet(Assertions::fail); + pinRepository.flush(); + + // then + topicRepository.findById(createRequest.topicId()) + .ifPresentOrElse( + topic -> assertThat(topic.getLastPinUpdatedAt()).isEqualTo(pin.getUpdatedAt()), + Assertions::fail + ); + } + @Test @DisplayName("권한이 없는 토픽에 핀을 수정하면 예외를 발생시킨다.") void update_FailByForbidden() { - long pinId = pinCommandService.save(authMember, createRequest); - - assertThatThrownBy(() -> pinCommandService.update( - new Guest(), pinId, new PinUpdateRequest("name", "description")) - ).isInstanceOf(PinForbiddenException.class); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); + assertThatThrownBy(() -> pinCommandService.update(new Guest(), pinId, new PinUpdateRequest("name", "update"))) + .isInstanceOf(PinForbiddenException.class); } @Test @DisplayName("핀을 삭제하면 해당 핀을 soft delete 하고, 해당 핀의 이미지들도 soft delete 한다.") void removeById_Success() { // given - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); Pin pin = pinRepository.findById(pinId).get(); PinImage.createPinImageAssociatedWithPin(BASE_IMAGE, pin); @@ -174,7 +212,7 @@ void removeById_Success() { @Test @DisplayName("권한이 없는 토픽의 핀을 삭제하면 예외를 발생시킨다.") void removeById_FailByForbidden() { - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); assertThatThrownBy(() -> pinCommandService.removeById(new Guest(), pinId)) .isInstanceOf(PinForbiddenException.class); @@ -184,7 +222,7 @@ void removeById_FailByForbidden() { @DisplayName("핀 id를 전달받아 해당하는 핀에 핀 이미지를 추가한다.") void addImage_Success() { // given - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); // when long pinImageId = savePinImageAndGetId(pinId); @@ -192,19 +230,33 @@ void addImage_Success() { // then pinImageRepository.findById(pinImageId) .ifPresentOrElse( - found -> assertThat(found.getImageUrl()).isEqualTo(BASE_IMAGE), + found -> assertThat(found.getImageUrl()).isNotNull(), Assertions::fail ); } + @Test + @DisplayName("이미지가 null 인 경우 예외를 발생시킨다.") + void addImage_FailByNull() { + // given + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); + + PinImageCreateRequest imageNullRequest = new PinImageCreateRequest(pinId, null); + + // when then + assertThatThrownBy(() -> pinCommandService.addImage(authMember, imageNullRequest)) + .isInstanceOf(ImageBadRequestException.class); + } + @Test @DisplayName("권한이 없는 토픽의 핀에 핀 이미지를 추가하면 예외를 발생시킨다.") void addImage_FailByForbidden() { // given - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); // when, then - assertThatThrownBy(() -> pinCommandService.addImage(new Guest(), new PinImageCreateRequest(pinId, BASE_IMAGE))) + assertThatThrownBy(() -> pinCommandService.addImage(new Guest(), new PinImageCreateRequest(pinId, + BASE_IMAGE_FILE))) .isInstanceOf(PinForbiddenException.class); } @@ -212,7 +264,7 @@ void addImage_FailByForbidden() { @DisplayName("핀 이미지의 id를 전달받아 해당하는 핀 이미지를 soft delete 한다.") void removeImageById_Success() { // given - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); long pinImageId = savePinImageAndGetId(pinId); // when @@ -220,14 +272,14 @@ void removeImageById_Success() { // then pinImageRepository.findById(pinImageId) - .ifPresentOrElse( - found -> assertThat(found.isDeleted()).isTrue(), - Assertions::fail - ); + .ifPresentOrElse( + found -> assertThat(found.isDeleted()).isTrue(), + Assertions::fail + ); } private long savePinImageAndGetId(long pinId) { - pinCommandService.addImage(authMember, new PinImageCreateRequest(pinId, BASE_IMAGE)); + pinCommandService.addImage(authMember, new PinImageCreateRequest(pinId, BASE_IMAGE_FILE)); List pinImages = pinQueryService.findDetailById(authMember, pinId) .images(); PinImageResponse pinImage = pinImages.get(0); @@ -237,7 +289,7 @@ private long savePinImageAndGetId(long pinId) { @Test @DisplayName("권한이 없는 토픽의 핀 이미지를 삭제하면 예외를 발생시킨다.") void removeImageById_FailByForbidden() { - long pinId = pinCommandService.save(authMember, createRequest); + long pinId = pinCommandService.save(authMember, List.of(BASE_IMAGE_FILE), createRequest); long pinImageId = savePinImageAndGetId(pinId); assertThatThrownBy(() -> pinCommandService.removeImageById(new Guest(), pinImageId)) diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinQueryServiceTest.java index 0815dbf9..25d0f59e 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinQueryServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/application/PinQueryServiceTest.java @@ -108,7 +108,7 @@ void findDetailById_Success() { // then assertThat(actual).usingRecursiveComparison() .ignoringFields("updatedAt") - .isEqualTo(PinDetailResponse.from(pin)); + .isEqualTo(PinDetailResponse.of(pin, Boolean.TRUE)); } @Test @@ -131,7 +131,7 @@ void findDetailById_FailByForbidden() { } @Test - @DisplayName("멤버 Id를 이용하여 그 멤버가 만든 모든 Pin을 확인할 수 있다.") + @DisplayName("회원 Id를 이용하여 그 회원이 만든 모든 Pin을 확인할 수 있다.") void findAllPinsByMemberId_success() { // given List expected = pinRepository.saveAll(List.of( diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinImageRepositoryTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinImageRepositoryTest.java index ad36bb0d..a8604271 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinImageRepositoryTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinImageRepositoryTest.java @@ -14,6 +14,7 @@ import com.mapbefine.mapbefine.topic.TopicFixture; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; +import java.util.List; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -87,4 +88,30 @@ void deleteAllByPinId_Success() { .extractingResultOf("isDeleted") .containsOnly(true); } + + @Test + @DisplayName("여러 핀을 Id로 삭제하면, 핀 이미지들도 soft-deleting 된다.") + void deleteAllByMemberId_Success() { + //given + Pin otherPin = pinRepository.save(PinFixture.create(pin.getLocation(), topic, member)); + + PinImage pinImage1 = PinImageFixture.create(pin); + PinImage pinImage2 = PinImageFixture.create(otherPin); + pinImageRepository.save(pinImage1); + pinImageRepository.save(pinImage2); + + //when + assertThat(pinImage1.isDeleted()).isFalse(); + assertThat(pinImage2.isDeleted()).isFalse(); + pinImageRepository.deleteAllByPinIds(List.of(pin.getId(),otherPin.getId())); + + //then + assertThat(pinImageRepository.findAllByPinId(pin.getId())) + .extractingResultOf("isDeleted") + .containsOnly(true); + assertThat(pinImageRepository.findAllByPinId(otherPin.getId())) + .extractingResultOf("isDeleted") + .containsOnly(true); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java index 508d938e..24bf7cde 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/domain/PinRepositoryTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.assertThat; import com.mapbefine.mapbefine.location.LocationFixture; -import com.mapbefine.mapbefine.location.domain.Coordinate; import com.mapbefine.mapbefine.location.domain.Location; import com.mapbefine.mapbefine.location.domain.LocationRepository; import com.mapbefine.mapbefine.member.MemberFixture; @@ -24,10 +23,6 @@ @DataJpaTest class PinRepositoryTest { - private static final Coordinate DEFAULT_COORDINATE = Coordinate.of( - 37.5152933, - 127.1029866 - ); @Autowired private TopicRepository topicRepository; @@ -88,4 +83,49 @@ void deleteAllByTopicId_Success() { .containsOnly(true); } + + @Test + @DisplayName("Member ID로 모든 핀을 soft-delete 할 수 있다.") + void deleteAllByMemberId_Success() { + //given + for (int i = 0; i < 10; i++) { + pinRepository.save(PinFixture.create(location, topic, member)); + } + + //when + assertThat(member.getCreatedPins()).hasSize(10) + .extractingResultOf("isDeleted") + .containsOnly(false); + pinRepository.deleteAllByMemberId(member.getId()); + + //then + List deletedPins = pinRepository.findAllByCreatorId(member.getId()); + assertThat(deletedPins).extractingResultOf("isDeleted") + .containsOnly(true); + } + + @Test + @DisplayName("다른 토픽에 존재하는 핀들이여도, Member ID로 모든 핀을 soft-delete 할 수 있다.") + void deleteAllByMemberIdInOtherTopics_Success() { + //given + Topic otherTopic = TopicFixture.createByName("otherTopic", member); + topicRepository.save(otherTopic); + + for (int i = 0; i < 10; i++) { + pinRepository.save(PinFixture.create(location, topic, member)); + pinRepository.save(PinFixture.create(location, otherTopic, member)); + } + + //when + assertThat(member.getCreatedPins()).hasSize(20) + .extractingResultOf("isDeleted") + .containsOnly(false); + pinRepository.deleteAllByMemberId(member.getId()); + + //then + List deletedPins = pinRepository.findAllByCreatorId(member.getId()); + assertThat(deletedPins).extractingResultOf("isDeleted") + .containsOnly(true); + } + } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/pin/presentation/PinControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/pin/presentation/PinControllerTest.java index 74252c88..a283aff6 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/pin/presentation/PinControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/pin/presentation/PinControllerTest.java @@ -10,11 +10,11 @@ import com.mapbefine.mapbefine.pin.application.PinCommandService; import com.mapbefine.mapbefine.pin.application.PinQueryService; import com.mapbefine.mapbefine.pin.dto.request.PinCreateRequest; -import com.mapbefine.mapbefine.pin.dto.request.PinImageCreateRequest; import com.mapbefine.mapbefine.pin.dto.request.PinUpdateRequest; import com.mapbefine.mapbefine.pin.dto.response.PinDetailResponse; import com.mapbefine.mapbefine.pin.dto.response.PinImageResponse; import com.mapbefine.mapbefine.pin.dto.response.PinResponse; +import java.io.File; import java.time.LocalDateTime; import java.util.List; import org.junit.jupiter.api.DisplayName; @@ -22,6 +22,8 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; class PinControllerTest extends RestDocsIntegration { @@ -37,7 +39,12 @@ class PinControllerTest extends RestDocsIntegration { @Test @DisplayName("핀 추가") void add() throws Exception { - given(pinCommandService.save(any(), any())).willReturn(1L); + given(pinCommandService.save(any(), any(), any())).willReturn(1L); + File mockFile = new File(getClass() + .getClassLoader() + .getResource("test.png") + .getPath()); + MultiValueMap param = new LinkedMultiValueMap<>(); PinCreateRequest pinCreateRequest = new PinCreateRequest( 1L, @@ -49,11 +56,14 @@ void add() throws Exception { 127 ); + param.add("images", List.of(mockFile)); + param.add("request", pinCreateRequest); + mockMvc.perform( MockMvcRequestBuilders.post("/pins") .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(pinCreateRequest)) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .content(objectMapper.writeValueAsString(param)) ).andDo(restDocs.document()); } @@ -93,6 +103,7 @@ void findById() throws Exception { "매튜", 37, 127, + Boolean.FALSE, LocalDateTime.now(), List.of(new PinImageResponse(1L, BASE_IMAGES.get(0))) ); @@ -146,16 +157,17 @@ void findAll() throws Exception { @Test @DisplayName("핀 이미지 추가") void addImage() throws Exception { - PinImageCreateRequest pinImageCreateRequest = new PinImageCreateRequest( - 1L, - "https://map-befine-official.github.io/favicon.png" - ); + String imageFilePath = getClass().getClassLoader() + .getResource("test.png") + .getPath(); + File mockFile = new File(imageFilePath); mockMvc.perform( MockMvcRequestBuilders.post("/pins/images") .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(pinImageCreateRequest)) + .content(objectMapper.writeValueAsString(1L)) + .content(objectMapper.writeValueAsString(mockFile)) ).andDo(restDocs.document()); } @@ -171,7 +183,7 @@ void removeImage() throws Exception { @Test - @DisplayName("멤버 Id를 입력하면 해당 멤버가 만든 핀 목록을 조회할 수 있다.") + @DisplayName("회원 Id를 입력하면 해당 회원이 만든 핀 목록을 조회할 수 있다.") void findAllPinsByMemberId() throws Exception { List pinResponses = List.of( new PinResponse( diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicFixture.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicFixture.java index 818e81f3..4dab6572 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicFixture.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicFixture.java @@ -1,6 +1,7 @@ package com.mapbefine.mapbefine.topic; +import com.mapbefine.mapbefine.image.FileFixture; import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.topic.domain.PermissionType; import com.mapbefine.mapbefine.topic.domain.Publicity; @@ -15,8 +16,8 @@ public class TopicFixture { public static Topic createPrivateAndGroupOnlyTopic(Member member) { return Topic.createTopicAssociatedWithCreator( - "토픽 멤버만 읽을 수 있는 토픽", - "토픽 멤버만 읽을 수 있습니다.", + "토픽 회원만 읽을 수 있는 토픽", + "토픽 회원만 읽을 수 있습니다.", IMAGE_URL, Publicity.PRIVATE, PermissionType.GROUP_ONLY, @@ -39,7 +40,7 @@ public static Topic createByName(String name, Member member) { return Topic.createTopicAssociatedWithCreator( name, "설명", - null, + IMAGE_URL, Publicity.PUBLIC, PermissionType.ALL_MEMBERS, member @@ -50,7 +51,7 @@ public static Topic createPrivateByName(String name, Member member) { return Topic.createTopicAssociatedWithCreator( name, "설명", - null, + IMAGE_URL, Publicity.PRIVATE, PermissionType.GROUP_ONLY, member @@ -62,7 +63,20 @@ public static TopicCreateRequest createPublicAndAllMembersCreateRequestWithPins( ) { return new TopicCreateRequest( "아무나 읽을 수 있는 토픽", - IMAGE_URL, + FileFixture.createFile(), + "아무나 읽을 수 있는 토픽입니다.", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + pinIds + ); + } + + public static TopicCreateRequest createPublicAndAllMembersAndEmptyImageCreateRequestWithPins( + List pinIds + ) { + return new TopicCreateRequest( + "아무나 읽을 수 있는 토픽", + null, "아무나 읽을 수 있는 토픽입니다.", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -75,7 +89,7 @@ public static TopicMergeRequest createPublicAndAllMembersMergeRequestWithTopics( ) { return new TopicMergeRequest( "아무나 읽을 수 있는 토픽", - IMAGE_URL, + FileFixture.createFile(), "아무나 읽을 수 있는 토픽입니다.", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java index 833e5333..20152e7f 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/TopicIntegrationTest.java @@ -20,13 +20,16 @@ import com.mapbefine.mapbefine.topic.domain.Publicity; import com.mapbefine.mapbefine.topic.domain.Topic; import com.mapbefine.mapbefine.topic.domain.TopicRepository; -import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest; +import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequest; +import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; -import io.restassured.*; -import io.restassured.response.*; +import io.restassured.RestAssured; +import io.restassured.response.ExtractableResponse; +import io.restassured.response.Response; +import java.io.File; import java.util.Collections; import java.util.List; import org.junit.jupiter.api.BeforeEach; @@ -57,6 +60,7 @@ class TopicIntegrationTest extends IntegrationTest { private Topic topic; private Location location; private String authHeader; + private File mockFile; @BeforeEach void setMember() { @@ -64,15 +68,19 @@ void setMember() { topic = topicRepository.save(TopicFixture.createPublicAndAllMembersTopic(member)); location = locationRepository.save(LocationFixture.create()); authHeader = testAuthHeaderProvider.createAuthHeader(member); - } + mockFile = new File( + getClass().getClassLoader() + .getResource("test.png") + .getPath() + ); + } @Test @DisplayName("Pin 목록 없이 Topic을 생성하면 201을 반환한다") void createNewTopicWithoutPins_Success() { - TopicCreateRequest 준팍의_또간집 = new TopicCreateRequest( + TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( "준팍의 또간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 2번 이상 간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -87,12 +95,22 @@ void createNewTopicWithoutPins_Success() { assertThat(response.header("Location")).isNotBlank(); } - private ExtractableResponse createNewTopic(TopicCreateRequest request, String authHeader) { + private ExtractableResponse createNewTopic(TopicCreateRequestWithoutImage request, String authHeader) { return RestAssured.given() .log().all() .header(AUTHORIZATION, authHeader) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(request) + .multiPart("image", mockFile, MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("request", request, MediaType.APPLICATION_JSON_VALUE) + .when().post("/topics/new") + .then().log().all() + .extract(); + } + + private ExtractableResponse createNewTopicExcludeImage(TopicCreateRequestWithoutImage request, String authHeader) { + return RestAssured.given() + .log().all() + .header(AUTHORIZATION, authHeader) + .multiPart("request", request, MediaType.APPLICATION_JSON_VALUE) .when().post("/topics/new") .then().log().all() .extract(); @@ -101,7 +119,6 @@ private ExtractableResponse createNewTopic(TopicCreateRequest request, @Test @DisplayName("Pin 목록과 함께 Topic을 생성하면 201을 반환한다") void createNewTopicWithPins_Success() { - PinFixture.create(location, topic, member); List pins = pinRepository.findAll(); @@ -109,9 +126,8 @@ void createNewTopicWithPins_Success() { .map(Pin::getId) .toList(); - TopicCreateRequest 준팍의_또간집 = new TopicCreateRequest( + TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( "준팍의 또간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 2번 이상 간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -126,21 +142,45 @@ void createNewTopicWithPins_Success() { assertThat(response.header("Location")).isNotBlank(); } + @Test + @DisplayName("Image 없이 Topic 을 생성하더라도 201을 반환한다") + void createNewTopicNonExistsImage_Success() { + PinFixture.create(location, topic, member); + + List pins = pinRepository.findAll(); + List pinIds = pins.stream() + .map(Pin::getId) + .toList(); + + TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( + "준팍의 또간집", + "준팍이 2번 이상 간집 ", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + pinIds + ); + + // when + ExtractableResponse response = createNewTopicExcludeImage(준팍의_또간집, authHeader); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + assertThat(response.header("Location")).isNotBlank(); + } + @Test @DisplayName("여러개의 토픽을 병합하면 201을 반환한다") void createMergeTopic_Success() { // given - TopicCreateRequest 준팍의_또간집 = new TopicCreateRequest( + TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( "준팍의 또간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 2번 이상 간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, Collections.emptyList() ); - TopicCreateRequest 준팍의_또안간집 = new TopicCreateRequest( + TopicCreateRequestWithoutImage 준팍의_또안간집 = new TopicCreateRequestWithoutImage( "준팍의 또안간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 2번 이상 안간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -153,20 +193,65 @@ void createMergeTopic_Success() { .map(Topic::getId) .toList(); - TopicMergeRequest 송파_데이트코스 = new TopicMergeRequest( + TopicMergeRequestWithoutImage 송파_데이트코스 = new TopicMergeRequestWithoutImage( "송파 데이트코스", - "https://map-befine-official.github.io/favicon.png", "맛집과 카페 토픽 합치기", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, topicIds); // when - ExtractableResponse response = RestAssured - .given().log().all() + ExtractableResponse response = RestAssured.given() + .log().all() .header(AUTHORIZATION, authHeader) - .contentType(MediaType.APPLICATION_JSON_VALUE) - .body(송파_데이트코스) + .multiPart("image", mockFile, MediaType.MULTIPART_FORM_DATA_VALUE) + .multiPart("request", 송파_데이트코스, MediaType.APPLICATION_JSON_VALUE) + .when().post("/topics/merge") + .then().log().all() + .extract(); + + // then + assertThat(response.statusCode()).isEqualTo(HttpStatus.CREATED.value()); + assertThat(response.header("Location")).isNotBlank(); + } + + @Test + @DisplayName("여러개의 토픽을 토픽 이미지 없이 병합해도 201을 반환한다") + void createMergeTopicWithoutImage_Success() { + // given + TopicCreateRequestWithoutImage 준팍의_또간집 = new TopicCreateRequestWithoutImage( + "준팍의 또간집", + "준팍이 2번 이상 간집 ", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + Collections.emptyList() + ); + TopicCreateRequestWithoutImage 준팍의_또안간집 = new TopicCreateRequestWithoutImage( + "준팍의 또안간집", + "준팍이 2번 이상 안간집 ", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + Collections.emptyList() + ); + createNewTopic(준팍의_또간집, authHeader); + createNewTopic(준팍의_또안간집, authHeader); + List topics = topicRepository.findAll(); + List topicIds = topics.stream() + .map(Topic::getId) + .toList(); + + TopicMergeRequestWithoutImage 송파_데이트코스 = new TopicMergeRequestWithoutImage( + "송파 데이트코스", + "맛집과 카페 토픽 합치기", + Publicity.PUBLIC, + PermissionType.ALL_MEMBERS, + topicIds); + + // when + ExtractableResponse response = RestAssured.given() + .log().all() + .header(AUTHORIZATION, authHeader) + .multiPart("request", 송파_데이트코스, MediaType.APPLICATION_JSON_VALUE) .when().post("/topics/merge") .then().log().all() .extract(); @@ -180,9 +265,8 @@ void createMergeTopic_Success() { @DisplayName("Topic을 수정하면 200을 반환한다") void updateTopic_Success() { ExtractableResponse newTopic = createNewTopic( - new TopicCreateRequest( + new TopicCreateRequestWithoutImage( "준팍의 또간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 두번 간집", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -217,9 +301,8 @@ void updateTopic_Success() { @DisplayName("Topic을 삭제하면 204를 반환한다") void deleteTopic_Success() { ExtractableResponse newTopic = createNewTopic( - new TopicCreateRequest( + new TopicCreateRequestWithoutImage( "준팍의 또간집", - "https://map-befine-official.github.io/favicon.png", "준팍이 두번 간집 ", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -262,9 +345,8 @@ void findTopics_Success() { @DisplayName("Topic 상세 정보를 조회하면 200을 반환한다") void findTopicDetail_Success() { //given - TopicCreateRequest request = new TopicCreateRequest( + TopicCreateRequestWithoutImage request = new TopicCreateRequestWithoutImage( "topicName", - "https://map-befine-official.github.io/favicon.png", "description", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -291,9 +373,8 @@ void findTopicDetail_Success() { @DisplayName("Topic 상세 정보 여러개를 조회하면 200을 반환한다") void findTopicDetailsByIds_Success() { //given - TopicCreateRequest request = new TopicCreateRequest( + TopicCreateRequestWithoutImage request = new TopicCreateRequestWithoutImage( "topicName", - "https://map-befine-official.github.io/favicon.png", "description", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, @@ -321,7 +402,6 @@ void findTopicDetailsByIds_Success() { // then assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); assertThat(responses).hasSize(2); - assertThat(responses).hasSize(2); } @Test @@ -363,6 +443,8 @@ void findAllBestTopics_Success() { @Test @DisplayName("핀이 추가/수정된 일자 기준 내림차순으로 토픽 목록을 조회할 경우, 200을 반환한다") void findAllByOrderByUpdatedAtDesc_Success() { + topicRepository.deleteAll(); + // given Topic topic1 = topicRepository.save(TopicFixture.createByName("topic1", member)); Topic topic2 = topicRepository.save(TopicFixture.createByName("topic2", member)); @@ -388,6 +470,7 @@ void findAllByOrderByUpdatedAtDesc_Success() { .then().log().all() .extract(); + // then List topicResponses = response.jsonPath().getList(".", TopicResponse.class); assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()); assertThat(topicResponses).hasSize(3); @@ -396,7 +479,7 @@ void findAllByOrderByUpdatedAtDesc_Success() { } @Test - @DisplayName("멤버별 Topic 목록을 조회하면 200을 반환한다") + @DisplayName("회원별 Topic 목록을 조회하면 200을 반환한다") void findAllTopicsByMemberId_Success() { // when ExtractableResponse response = RestAssured diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java index be526608..90459094 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicCommandServiceTest.java @@ -73,7 +73,7 @@ void setUp() { @Test @DisplayName("비어있는 토픽을 생성할 수 있다.") - public void saveEmptyTopic_Success() { + void saveEmptyTopic_Success() { //given TopicCreateRequest request = TopicFixture.createPublicAndAllMembersCreateRequestWithPins( @@ -92,9 +92,33 @@ public void saveEmptyTopic_Success() { assertThat(detail.pinCount()).isEqualTo(request.pins().size()); } + @Test + @DisplayName("이미지 없이 토픽을 생성하면 기본 이미지를 반환한다.") + void saveEmptyTopicAndEmptyImage_Success() { + //given + TopicCreateRequest request = + TopicFixture.createPublicAndAllMembersAndEmptyImageCreateRequestWithPins( + Collections.emptyList() + ); + + //when + Long topicId = topicCommandService.saveTopic(user, request); + + //then + TopicDetailResponse detail = topicQueryService.findDetailById(user, topicId); + + assertThat(detail.id()).isEqualTo(topicId); + assertThat(detail.name()).isEqualTo(request.name()); + assertThat(detail.description()).isEqualTo(request.description()); + assertThat(detail.pinCount()).isEqualTo(request.pins().size()); + assertThat(detail.image()).isEqualTo( + "https://velog.velcdn.com/images/semnil5202/post/37f3bcb9-0b07-4100-85f6-f1d5ad037c14/image.svg" + ); + } + @Test @DisplayName("Guest는 비어있는 토픽을 생성할 수 없다.") - public void saveEmptyTopic_Fail() { + void saveEmptyTopic_Fail() { //given TopicCreateRequest request = TopicFixture.createPublicAndAllMembersCreateRequestWithPins( @@ -108,7 +132,7 @@ public void saveEmptyTopic_Fail() { @Test @DisplayName("핀을 통해 새로운 토픽을 생성할 수 있다.") - public void saveTopicWithPins_Success() { + void saveTopicWithPins_Success() { //given Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); @@ -139,7 +163,7 @@ public void saveTopicWithPins_Success() { @Test @DisplayName("Guest는 핀을 통해 새로운 토픽을 생성할 수 없다.") - public void saveTopicWithPins_Fail1() { + void saveTopicWithPins_Fail1() { //given Topic publicAndAllMembersTopic = TopicFixture.createPublicAndAllMembersTopic(member); @@ -160,7 +184,7 @@ public void saveTopicWithPins_Fail1() { @Test @DisplayName("권한이 없는 핀을 통해 토픽을 생성할 수 없다.") - public void saveTopicWithPins_Fail2() { + void saveTopicWithPins_Fail2() { //given Member topicOwner = MemberFixture.create( "topicOwner", @@ -187,7 +211,7 @@ public void saveTopicWithPins_Fail2() { @Test @DisplayName("기존의 토픽들을 통해 새로운 토픽을 생성할 수 있다.") - public void merge_Success() { + void merge_Success() { //given Topic topic1 = TopicFixture.createPublicAndAllMembersTopic(member); Topic topic2 = TopicFixture.createPublicAndAllMembersTopic(member); @@ -224,7 +248,7 @@ public void merge_Success() { @Test @DisplayName("Guest는 기존의 토픽들을 통해 새로운 토픽을 생성할 수 없다.") - public void merge_Fail1() { + void merge_Fail1() { //given Topic privateAndGroupOnlyTopic = TopicFixture.createPrivateAndGroupOnlyTopic(member); topicRepository.save(privateAndGroupOnlyTopic); @@ -241,7 +265,7 @@ public void merge_Fail1() { @Test @DisplayName("권한이 없는 토픽들을 통해 새로운 토픽을 생성할 수 없다.") - public void merge_Fail2() { + void merge_Fail2() { //given Member topicOwner = MemberFixture.create( "topicOwner", @@ -297,14 +321,14 @@ void copyPin_Success() { @Test @DisplayName("토픽의 정보를 수정할 수 있다.") - public void updateTopicInfo_Success() { + void updateTopicInfo_Success() { //given Topic topic = TopicFixture.createPrivateAndGroupOnlyTopic(member); topicRepository.save(topic); //when - assertThat(topic.getTopicInfo().getName()).isEqualTo("토픽 멤버만 읽을 수 있는 토픽"); - assertThat(topic.getTopicInfo().getDescription()).isEqualTo("토픽 멤버만 읽을 수 있습니다."); + assertThat(topic.getTopicInfo().getName()).isEqualTo("토픽 회원만 읽을 수 있는 토픽"); + assertThat(topic.getTopicInfo().getDescription()).isEqualTo("토픽 회원만 읽을 수 있습니다."); AuthMember user = MemberFixture.createUser(member); TopicUpdateRequest request = new TopicUpdateRequest( @@ -326,7 +350,7 @@ public void updateTopicInfo_Success() { @Test @DisplayName("권한이 없는 토픽의 정보를 수정할 수 없다.") - public void updateTopicInfo_Fail() { + void updateTopicInfo_Fail() { //given Member topicOwner = MemberFixture.create( "topicOwner", @@ -353,7 +377,7 @@ public void updateTopicInfo_Fail() { @Test @DisplayName("Admin은 토픽을 삭제할 수 있다.") - public void delete_Success() { + void delete_Success() { //given Member admin = MemberFixture.create( "topicOwner", @@ -380,7 +404,7 @@ public void delete_Success() { @Test @DisplayName("Admin이 아닌 경우, 토픽을 삭제할 수 없다.") - public void delete_Fail() { + void delete_Fail() { //given Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); topicRepository.save(topic); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java index 003cfb7f..00374715 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/application/TopicQueryServiceTest.java @@ -80,8 +80,8 @@ void findAllReadable_Success1() { assertThat(topics).extractingResultOf("name") .containsExactlyInAnyOrder( "아무나 읽을 수 있는 토픽", - "토픽 멤버만 읽을 수 있는 토픽", - "토픽 멤버만 읽을 수 있는 토픽" + "토픽 회원만 읽을 수 있는 토픽", + "토픽 회원만 읽을 수 있는 토픽" ); } @@ -134,6 +134,26 @@ void findDetailById_Success() { assertThat(detail.name()).isEqualTo("아무나 읽을 수 있는 토픽"); } + @Test + @DisplayName("토픽 상세 조회 시 토픽의 변경일자는 핀의 최신 변경 일자이다.") + void findDetailById_Success_lastPinUpdatedAt() { + //given + Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); + Location location = LocationFixture.create(); + Pin pin = PinFixture.create(location, topic, member); + locationRepository.save(location); + topicRepository.save(topic); + pinRepository.save(pin); + + //when + pin.updatePinInfo("updatePin", "updatedAt will be update"); + pinRepository.flush(); + TopicDetailResponse response = topicQueryService.findDetailById(new Admin(member.getId()), topic.getId()); + + //then + assertThat(response.updatedAt()).isEqualTo(pin.getUpdatedAt()); + } + @Test @DisplayName("권한이 없는 토픽을 ID로 조회하면, 예외가 발생한다.") void findDetailById_Fail() { @@ -194,7 +214,7 @@ void findDetailByIds_Success2() { //then assertThat(details).hasSize(2); assertThat(details).extractingResultOf("name") - .containsExactlyInAnyOrder("아무나 읽을 수 있는 토픽", "토픽 멤버만 읽을 수 있는 토픽"); + .containsExactlyInAnyOrder("아무나 읽을 수 있는 토픽", "토픽 회원만 읽을 수 있는 토픽"); } @Test @@ -235,7 +255,7 @@ void findDetailByIds_Fail2() { @Test @DisplayName("모든 토픽을 조회할 때, 즐겨찾기 여부를 함께 반환한다.") - public void findAllReadableWithBookmark_Success() { + void findAllReadableWithBookmark_Success() { //given Topic topic1 = TopicFixture.createPublicAndAllMembersTopic(member); Topic topic2 = TopicFixture.createPublicAndAllMembersTopic(member); @@ -257,8 +277,8 @@ public void findAllReadableWithBookmark_Success() { } @Test - @DisplayName("모든 토픽을 조회할 때, 로그인 유저가 아니면 즐겨찾기 여부가 항상 False다") - public void findAllReadableWithoutBookmark_Success() { + @DisplayName("모든 토픽을 조회할 때, 로그인 회원이 아니면 즐겨찾기 여부가 항상 False다") + void findAllReadableWithoutBookmark_Success() { //given Topic topic1 = TopicFixture.createPublicAndAllMembersTopic(member); Topic topic2 = TopicFixture.createPublicAndAllMembersTopic(member); @@ -280,8 +300,8 @@ public void findAllReadableWithoutBookmark_Success() { } @Test - @DisplayName("토픽 상세조회시, 즐겨찾기 여부를 함께 반환한다.") - public void findWithBookmarkStatus_Success() { + @DisplayName("토픽 상세조회시, 즐겨찾기 여부, 모아보기 여부, 수정 권한 여부를 함께 반환한다.") + void findWithBookmarkStatus_Success() { //given Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); topicRepository.save(topic); @@ -295,12 +315,13 @@ public void findWithBookmarkStatus_Success() { assertThat(topicDetail.id()).isEqualTo(topic.getId()); assertThat(topicDetail.isBookmarked()).isEqualTo(Boolean.TRUE); - + assertThat(topicDetail.isInAtlas()).isEqualTo(Boolean.FALSE); + assertThat(topicDetail.canUpdate()).isEqualTo(Boolean.TRUE); } @Test - @DisplayName("토픽 상세조회시, 로그인 유저가 아니라면 즐겨찾기 여부가 항상 False다.") - public void findWithoutBookmarkStatus_Success() { + @DisplayName("토픽 상세조회시, 로그인 회원이 아니라면 즐겨찾기 여부, 모아보기 여부, 수정 권한 여부가 항상 False다.") + void findWithoutBookmarkStatus_Success() { //given Topic topic = TopicFixture.createPublicAndAllMembersTopic(member); topicRepository.save(topic); @@ -314,11 +335,13 @@ public void findWithoutBookmarkStatus_Success() { assertThat(topicDetail.id()).isEqualTo(topic.getId()); assertThat(topicDetail.isBookmarked()).isEqualTo(Boolean.FALSE); + assertThat(topicDetail.isInAtlas()).isEqualTo(Boolean.FALSE); + assertThat(topicDetail.canUpdate()).isEqualTo(Boolean.FALSE); } @Test @DisplayName("여러 토픽 조회시, 즐겨 찾기 여부를 함께 반환한다.") - public void findDetailsWithBookmarkStatus_Success() { + void findDetailsWithBookmarkStatus_Success() { //given Topic topic1 = TopicFixture.createPublicAndAllMembersTopic(member); Topic topic2 = TopicFixture.createPublicAndAllMembersTopic(member); @@ -341,8 +364,8 @@ public void findDetailsWithBookmarkStatus_Success() { } @Test - @DisplayName("여러 토픽 조회시, 로그인 유저가 아니라면 즐겨 찾기 여부가 항상 False다.") - public void findDetailsWithoutBookmarkStatus_Success() { + @DisplayName("여러 토픽 조회시, 로그인 회원이 아니라면 즐겨 찾기 여부가 항상 False다.") + void findDetailsWithoutBookmarkStatus_Success() { //given Topic topic1 = TopicFixture.createPublicAndAllMembersTopic(member); Topic topic2 = TopicFixture.createPublicAndAllMembersTopic(member); @@ -365,7 +388,7 @@ public void findDetailsWithoutBookmarkStatus_Success() { } @Test - @DisplayName("멤버 Id를 이용하여 그 멤버가 만든 모든 Topic을 확인할 수 있다.") + @DisplayName("회원 Id를 이용하여 그 회원이 만든 모든 Topic을 확인할 수 있다.") void findAllTopicsByMemberId_Success() { //given AuthMember authMember = new Admin(member.getId()); @@ -421,7 +444,7 @@ void findAllByOrderByUpdatedAtDesc_Success() { @Test @DisplayName("즐겨찾기가 많이 있는 토픽 순서대로 조회할 수 있다.") - public void findAllBestTopics_Success1() { + void findAllBestTopics_Success1() { //given Member otherMember = MemberFixture.create( "otherMember", @@ -456,7 +479,7 @@ public void findAllBestTopics_Success1() { @Test @DisplayName("즐겨찾기 순서대로 조회하더라도, private 토픽인 경우 조회할 수 없다.") - public void findAllBestTopics_Success2() { + void findAllBestTopics_Success2() { //given Member otherMember = MemberFixture.create( "otherMember", diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicInfoTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicInfoTest.java index a3e15bae..ed026493 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicInfoTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicInfoTest.java @@ -116,7 +116,8 @@ void whenImageNull_thenReturnDefault() { assertThat(topicInfo.getName()).isEqualTo(validName); assertThat(topicInfo.getDescription()).isEqualTo(validDescription); assertThat(topicInfo.getImageUrl()).isEqualTo( - "https://map-befine-official.github.io/favicon.png"); + "https://velog.velcdn.com/images/semnil5202/post/37f3bcb9-0b07-4100-85f6-f1d5ad037c14/image.svg" + ); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicRepositoryTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicRepositoryTest.java index 3280df30..2a783b70 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicRepositoryTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicRepositoryTest.java @@ -6,6 +6,8 @@ import com.mapbefine.mapbefine.member.domain.Member; import com.mapbefine.mapbefine.member.domain.MemberRepository; import com.mapbefine.mapbefine.member.domain.Role; +import com.mapbefine.mapbefine.topic.TopicFixture; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,24 +27,14 @@ class TopicRepositoryTest { @BeforeEach void setUp() { - member = MemberFixture.create("member", "member@naver.com", Role.USER); - memberRepository.save(member); + member = memberRepository.save(MemberFixture.create("member", "member@naver.com", Role.USER)); } @Test @DisplayName("토픽을 삭제하면, soft-deleting 된다.") void deleteById_Success() { //given - Topic topic = Topic.createTopicAssociatedWithCreator( - "토픽", - "토픽설명", - "https://example.com/image.jpg", - Publicity.PUBLIC, - PermissionType.ALL_MEMBERS, - member - ); - - topicRepository.save(topic); + Topic topic = topicRepository.save(TopicFixture.createByName("Topic", member)); assertThat(topic.isDeleted()).isFalse(); @@ -54,4 +46,24 @@ void deleteById_Success() { assertThat(deletedTopic.isDeleted()).isTrue(); } + @Test + @DisplayName("Member Id로 모든 토픽을 삭제하면, soft-deleting 된다.") + void deleteAllByMemberId_Success() { + //given + for (int i = 0; i < 10; i++) { + topicRepository.save(TopicFixture.createByName("topic" + i, member)); + } + assertThat(member.getCreatedTopics()).hasSize(10) + .extractingResultOf("isDeleted") + .containsOnly(false); + + //when + topicRepository.deleteAllByMemberId(member.getId()); + + //then + List deletedTopics = topicRepository.findAllByCreatorId(member.getId()); + assertThat(deletedTopics).hasSize(10) + .extractingResultOf("isDeleted") + .containsOnly(true); + } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicStatusTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicStatusTest.java index 74667963..6b31aba7 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicStatusTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicStatusTest.java @@ -76,7 +76,7 @@ void whenPublicAndAllMembers_thenSuccess() { @Test @DisplayName("공개 범위가 null인 경우, 예외가 발생한다.") - public void whenPublicityIsNull_Fail() { + void whenPublicityIsNull_Fail() { //given Publicity publicity = null; PermissionType permissionType = PermissionType.ALL_MEMBERS; @@ -88,7 +88,7 @@ public void whenPublicityIsNull_Fail() { @Test @DisplayName("권한 설정이 null인 경우, 예외가 발생한다.") - public void whenPermissionIsNull_Fail() { + void whenPermissionIsNull_Fail() { //given Publicity publicity = Publicity.PUBLIC; PermissionType permissionType = null; @@ -104,7 +104,7 @@ public void whenPermissionIsNull_Fail() { class Update { @Test - @DisplayName("권한 범위가 모든 멤버이면, 공개 범위를 혼자 볼 지도로 설정할 때 예외가 발생한다") + @DisplayName("권한 범위가 모든 회원이면, 공개 범위를 혼자 볼 지도로 설정할 때 예외가 발생한다") void whenAllMembersAndPrivate_thenFail() { //given TopicStatus topicStatus = TopicStatus.of(Publicity.PUBLIC, PermissionType.ALL_MEMBERS); diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicTest.java index 0d5b02a4..3d98d2dc 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/domain/TopicTest.java @@ -8,7 +8,9 @@ import com.mapbefine.mapbefine.member.domain.Role; import com.mapbefine.mapbefine.pin.PinFixture; import com.mapbefine.mapbefine.pin.domain.Pin; +import java.time.LocalDateTime; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; class TopicTest { @@ -33,6 +35,7 @@ void setUp() { } @Test + @DisplayName("토픽 정보를 변경한다.") void updateTopicInfo() { //given String name = "New Topic"; @@ -54,6 +57,7 @@ void updateTopicInfo() { } @Test + @DisplayName("토픽 상태를 변경한다.") void updateTopicStatus() { //given Publicity publicity = Publicity.PRIVATE; @@ -72,6 +76,7 @@ void updateTopicStatus() { } @Test + @DisplayName("토픽의 핀 개수를 알 수 있다.") void countPins() { //given topic.addPin(pin); @@ -81,15 +86,27 @@ void countPins() { //then assertThat(pinCounts).isEqualTo(2); - // 핀이 처음 생성됐을 때, 연관관계 매핑으로 인해 하나 추가된 후 해당 메서드에서 하나 더 추가됌 } @Test + @DisplayName("토픽에 직접 핀을 추가해도, 조회 전용이므로 핀 변경 일시는 반영되지 않는다.") void addPin() { //when + LocalDateTime beforeAdding = topic.getLastPinUpdatedAt(); topic.addPin(pin); //then assertThat(topic.getPins()).contains(pin); + assertThat(topic.getLastPinUpdatedAt()).isEqualTo(beforeAdding); + } + + @Test + @DisplayName("핀 없이 생성된 토픽의 초기 핀 추가 일시는 토픽 변경 일시와 같다.") + void create() { + // when + Topic emptyPinsTopic = new Topic(); + + //then + assertThat(emptyPinsTopic.getCreatedAt()).isEqualTo(emptyPinsTopic.getLastPinUpdatedAt()); } } diff --git a/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java b/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java index 4fd2e7f9..261e558d 100644 --- a/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java +++ b/backend/src/test/java/com/mapbefine/mapbefine/topic/presentation/TopicControllerTest.java @@ -10,18 +10,22 @@ import com.mapbefine.mapbefine.topic.application.TopicQueryService; import com.mapbefine.mapbefine.topic.domain.PermissionType; import com.mapbefine.mapbefine.topic.domain.Publicity; -import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequest; -import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequest; +import com.mapbefine.mapbefine.topic.dto.request.TopicCreateRequestWithoutImage; +import com.mapbefine.mapbefine.topic.dto.request.TopicMergeRequestWithoutImage; import com.mapbefine.mapbefine.topic.dto.request.TopicUpdateRequest; import com.mapbefine.mapbefine.topic.dto.response.TopicDetailResponse; import com.mapbefine.mapbefine.topic.dto.response.TopicResponse; +import java.io.File; import java.time.LocalDateTime; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; class TopicControllerTest extends RestDocsIntegration { // TODO: 2023/07/25 Image 칼람 추가됨으로 인해 수정 필요 @@ -47,54 +51,73 @@ class TopicControllerTest extends RestDocsIntegration { // TODO: 2023/07/25 Imag LocalDateTime.now() )); + private File mockFile; + @MockBean private TopicCommandService topicCommandService; @MockBean private TopicQueryService topicQueryService; + @BeforeEach + void setUp() { + mockFile = new File( + getClass().getClassLoader() + .getResource("test.png") + .getPath() + ); + } + @Test @DisplayName("토픽 새로 생성") void create() throws Exception { given(topicCommandService.saveTopic(any(), any())).willReturn(1L); + File mockFile = new File(getClass().getClassLoader().getResource("test.png").getPath()); + MultiValueMap param = new LinkedMultiValueMap<>(); - TopicCreateRequest topicCreateRequest = new TopicCreateRequest( + TopicCreateRequestWithoutImage request = new TopicCreateRequestWithoutImage( "준팍의 안갈집", - "https://map-befine-official.github.io/favicon.png", "준팍이 두번 다시 안갈집", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, List.of(1L, 2L, 3L) ); + param.add("image", mockFile); + param.add("request", request); + mockMvc.perform( MockMvcRequestBuilders.post("/topics/new") .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(topicCreateRequest)) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .content(objectMapper.writeValueAsString(param)) ).andDo(restDocs.document()); } @Test @DisplayName("토픽 병합 생성") void mergeAndCreate() throws Exception { - given(topicCommandService.merge(any(), any())).willReturn(1L); - TopicMergeRequest topicMergeRequest = new TopicMergeRequest( + MultiValueMap param = new LinkedMultiValueMap<>(); + + + TopicMergeRequestWithoutImage request = new TopicMergeRequestWithoutImage( "준팍의 안갈집", - "https://map-befine-official.github.io/favicon.png", "준팍이 두번 다시 안갈집", Publicity.PUBLIC, PermissionType.ALL_MEMBERS, List.of(1L, 2L, 3L) ); + param.add("image", mockFile); + param.add("request", request); + mockMvc.perform( MockMvcRequestBuilders.post("/topics/merge") .header(AUTHORIZATION, testAuthHeaderProvider.createAuthHeaderById(1L)) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(topicMergeRequest)) + .contentType(MediaType.MULTIPART_FORM_DATA_VALUE) + .content(objectMapper.writeValueAsString(param)) ).andDo(restDocs.document()); } @@ -160,6 +183,7 @@ void findById() throws Exception { Boolean.FALSE, 0, Boolean.FALSE, + Boolean.FALSE, LocalDateTime.now(), List.of( new PinResponse( @@ -201,7 +225,7 @@ void findAllByOrderByUpdatedAtDesc() throws Exception { } @Test - @DisplayName("멤버 Id를 입력하면 해당 멤버가 만든 지도 목록을 조회할 수 있다.") + @DisplayName("회원 Id를 입력하면 해당 회원이 만든 지도 목록을 조회할 수 있다.") void findAllTopicsByMemberId() throws Exception { given(topicQueryService.findAllTopicsByMemberId(any(), any())).willReturn(RESPONSES); diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 109e8daf..4fd6070f 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -1,10 +1,15 @@ spring: + profiles: + active: test + datasource: url: jdbc:h2:mem:mapbefine;MODE=MySQL username: sa + sql: init: data-locations: + jpa: show-sql: true hibernate: @@ -21,10 +26,6 @@ spring: logging: level: org.hibernate.orm.jdbc.bind: trace - org.hibernate.type: trace - org.hibernate.stat: debug - pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} %msg %n" oauth: kakao: @@ -37,4 +38,5 @@ security: jwt: token: secret-key: ${TEST_JWT_SECRET_KEY} - expire-length: ${TEST_JWT_EXPIRE_LENGTH} + access-expire-length: ${TEST_JWT_ACCESS_EXPIRE_LENGTH} + refresh-expire-length: ${TEST_JWT_REFRESH_EXPIRE_LENGTH} diff --git a/backend/src/test/resources/logback-test.xml b/backend/src/test/resources/logback-test.xml deleted file mode 100644 index af76e11c..00000000 --- a/backend/src/test/resources/logback-test.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - ${LOG_PATTERN} - - - - - - - - - diff --git a/backend/src/test/resources/test.png b/backend/src/test/resources/test.png new file mode 100644 index 00000000..e69de29b