From acb9bad84acc1090350a2ae67b37c6f9e88dece1 Mon Sep 17 00:00:00 2001 From: "Choi, Minwoo" Date: Thu, 24 Jul 2025 21:21:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=A9=A4=EB=B2=84=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Member에 update, updateDeletedAt 메서드 추가 * feat: MemberErrorCode에 MEMBER_NICKNAME_DUPLICATED 에러코드 추가 * feat: MemberRepository에 findByIdAndDeletedAtIsNull 메서드 추가 * feat: MemberPolicy에 validateNotDuplicated, validateNotDeleted 메서드 추가 * feat: MemberService에 getActiveMemberById 메서드 추가 * feat: CreateMemberCommand 추가 * feat: MemberInfo, MemberDetail 추가 * feat: UpdateMemberRequest 추가 * feat: MemberFacade 구현 * feat: MemberController 구현 * feat: LoadMemberDetailResponse 추가 * feat: StudyLog 엔티티에 Member 연관관계 매핑 추가 * feat: StudyLogFactory 구현 * feat: StudyLogQueryRepository, StudyLogQueryRepositoryAdapter 구현 * feat: StudyLogService 구현 * feat: TripQueryRepository에 countActiveTripsByMemberIdAndCategory 메서드 추가 * feat: TripService에 getActiveTripCountsByMemberId 메서드 추가 * feat: TripCount 추가 * refactor: MemberService.createMemberFromKakao() 비즈니스 로직 개선 * refactor: MemberFactory.fromKakao() -> MemberFactory.createFromKakao() 이름 변경 * refactor: MissionServiceTest, MissionControllerIntegrationTest에 @DisplayName 어노테이션 추가 * refactor: MissionControllerIntegrationTest 메서드명 개선 * refactor: Member에서 nickname 필드 unique = true 설정 제거 * test: MemberFactory를 적용하여 MemberFixture 개선 * test: KakoOauthFixture 삭제 * test: CreateMemberCommandFixture 추가 * test: UpdateMemberRequestFixture 추가 * test: KakaoLoginRequestFixture, KakaoSignupRequestFixture 추가 * test: KakaoTokenResponseFixture, KakaoUserInfoResponseFixture 추가 * test: MemberServiceTest에 UpdateNicknameAndCategoryIfPresent, DeleteMember, GetActiveMemberById 단위 테스트 추가 * test: MemberControllerIntegrationTest 통합 테스트 추가 * test: StudyLogServiceTest 단위 테스트 추가 * test: TripServiceTest에 GetActiveTripCountsByMemberId 단위 테스트 추가 * chore: 닉네임 중복 검사 API에 인증 없이 접근 가능하도록 Spring Security 설정 추가 --- .../auth/application/facade/AuthFacade.java | 14 +- .../controller/AuthController.java | 5 +- .../global/common/constants/UrlConstants.java | 4 +- .../application/dto/CreateMemberCommand.java | 9 + .../member/application/dto/MemberDetail.java | 10 + .../member/application/dto/MemberInfo.java | 35 ++ .../application/facade/MemberFacade.java | 42 +++ .../application/service/MemberService.java | 58 +++- .../member/domain/error/MemberErrorCode.java | 12 +- .../studytrip/member/domain/model/Member.java | 19 +- .../member/domain/model/MemberCategory.java | 6 - .../member/domain/policy/MemberPolicy.java | 16 +- .../domain/repository/MemberRepository.java | 2 + .../member/factory/MemberFactory.java | 2 +- .../member/infra/jpa/MemberJpaRepository.java | 2 + .../infra/jpa/MemberRepositoryAdapter.java | 5 + .../controller/MemberController.java | 59 ++++ .../dto/request/UpdateMemberRequest.java | 16 + .../response/LoadMemberDetailResponse.java | 29 ++ .../application/service/StudyLogService.java | 17 + .../domain/factory/StudyLogFactory.java | 15 + .../studylog/domain/model/StudyLog.java | 14 +- .../repository/StudyLogQueryRepository.java | 5 + .../StudyLogQueryRepositoryAdapter.java | 27 ++ .../trip/application/dto/TripCount.java | 7 + .../trip/application/service/TripService.java | 13 + .../repository/TripQueryRepository.java | 3 + .../querydsl/TripQueryRepositoryAdapter.java | 17 + .../service/KakaoLoginServiceTest.java | 9 +- .../fixture/KakaoLoginRequestFixture.java | 16 + .../auth/fixture/KakaoOauthFixture.java | 35 -- .../fixture/KakaoSignupRequestFixture.java | 28 ++ .../fixture/KakaoTokenResponseFixture.java | 11 + .../fixture/KakaoUserInfoResponseFixture.java | 37 +++ .../AuthControllerIntegrationTest.java | 168 ++++------ .../service/MemberServiceTest.java | 297 ++++++++++++----- .../fixture/CreateMemberCommandFixture.java | 40 +++ .../member/fixture/MemberFixture.java | 48 +-- .../fixture/UpdateMemberRequestFixture.java | 22 ++ .../MemberControllerIntegrationTest.java | 300 ++++++++++++++++++ .../service/MissionServiceTest.java | 5 +- .../MissionControllerIntegrationTest.java | 49 +-- .../service/StudyLogServiceTest.java | 61 ++++ .../application/service/TripServiceTest.java | 48 +++ 44 files changed, 1322 insertions(+), 315 deletions(-) create mode 100644 src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java create mode 100644 src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java create mode 100644 src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java create mode 100644 src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java create mode 100644 src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java create mode 100644 src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java create mode 100644 src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java create mode 100644 src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java create mode 100644 src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java create mode 100644 src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java delete mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java create mode 100644 src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java create mode 100644 src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java create mode 100644 src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java create mode 100644 src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java create mode 100644 src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java diff --git a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java index 40099ed..f226f6b 100644 --- a/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java +++ b/src/main/java/com/ject/studytrip/auth/application/facade/AuthFacade.java @@ -5,6 +5,7 @@ import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; import com.ject.studytrip.auth.presentation.dto.response.TokenResponse; +import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.application.service.MemberService; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.SocialProvider; @@ -19,21 +20,26 @@ public class AuthFacade { public TokenResponse kakaoLogin(KakaoLoginRequest request) { KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code()); + Member member = memberService.getMemberBySocialProviderAndSocialId( SocialProvider.KAKAO, response.kakaoId()); + return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); } public TokenResponse kakaoSignup(KakaoSignupRequest request) { KakaoUserInfoResponse response = kakaoLoginService.getKakaoUserInfo(request.code()); - Member member = - memberService.createMemberFromKakao( + CreateMemberCommand command = + CreateMemberCommand.of( response.kakaoId(), response.getEmail(), response.getProfileImage(), - request.category(), - request.nickname()); + request.nickname(), + request.category()); + + Member member = memberService.createMemberFromKakao(command); + return kakaoLoginService.getTokens(member.getId().toString(), member.getRole().name()); } } diff --git a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java index 4ccd24d..744a8f4 100644 --- a/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java +++ b/src/main/java/com/ject/studytrip/auth/presentation/controller/AuthController.java @@ -9,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -27,7 +28,7 @@ public class AuthController { public ResponseEntity kakaoLogin( @Valid @RequestBody KakaoLoginRequest request) { TokenResponse response = authFacade.kakaoLogin(request); - return ResponseEntity.ok(StandardResponse.success(200, response)); + return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); } @Operation( @@ -37,6 +38,6 @@ public ResponseEntity kakaoLogin( public ResponseEntity kakaoSignup( @Valid @RequestBody KakaoSignupRequest request) { TokenResponse response = authFacade.kakaoSignup(request); - return ResponseEntity.ok(StandardResponse.success(200, response)); + return ResponseEntity.ok(StandardResponse.success(HttpStatus.OK.value(), response)); } } diff --git a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java index 5e8bec4..bdbb4ae 100644 --- a/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java +++ b/src/main/java/com/ject/studytrip/global/common/constants/UrlConstants.java @@ -10,8 +10,8 @@ public enum UrlConstants { LOCAL_API_SERVER_URL("http://localhost:8080"), // TODO: 개발, 운영 도메인 URL 추가 작업 - LOCAL_DOMAIN_URL("http://localhost:3000"), - LOCAL_SECURE_DOMAIN_URL("https://localhost:3000"), + LOCAL_DOMAIN_URL("http://localhost:5173"), + LOCAL_SECURE_DOMAIN_URL("https://localhost:5173"), ; private final String value; diff --git a/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java b/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java new file mode 100644 index 0000000..051d8a8 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/dto/CreateMemberCommand.java @@ -0,0 +1,9 @@ +package com.ject.studytrip.member.application.dto; + +public record CreateMemberCommand( + String socialId, String email, String profileImage, String nickname, String category) { + public static CreateMemberCommand of( + String socialId, String email, String profileImage, String nickname, String category) { + return new CreateMemberCommand(socialId, email, profileImage, nickname, category); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java b/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java new file mode 100644 index 0000000..a8ce875 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/dto/MemberDetail.java @@ -0,0 +1,10 @@ +package com.ject.studytrip.member.application.dto; + +import com.ject.studytrip.trip.application.dto.TripCount; + +public record MemberDetail(MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { + public static MemberDetail from( + MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { + return new MemberDetail(memberInfo, tripCount, studyLogCount); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java b/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java new file mode 100644 index 0000000..373ea57 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/dto/MemberInfo.java @@ -0,0 +1,35 @@ +package com.ject.studytrip.member.application.dto; + +import com.ject.studytrip.global.util.DateUtil; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.domain.model.MemberCategory; +import com.ject.studytrip.member.domain.model.MemberRole; +import com.ject.studytrip.member.domain.model.SocialProvider; + +public record MemberInfo( + Long memberId, + SocialProvider socialProvider, + String socialId, + String email, + String nickname, + String profileImage, + MemberCategory category, + MemberRole role, + String createdAt, + String updatedAt, + String deletedAt) { + public static MemberInfo from(Member member) { + return new MemberInfo( + member.getId(), + member.getSocialProvider(), + member.getSocialId(), + member.getEmail(), + member.getNickname(), + member.getProfileImage(), + member.getCategory(), + member.getRole(), + DateUtil.formatDateTime(member.getCreatedAt()), + DateUtil.formatDateTime(member.getUpdatedAt()), + DateUtil.formatDateTime(member.getDeletedAt())); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java new file mode 100644 index 0000000..7a71a35 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/application/facade/MemberFacade.java @@ -0,0 +1,42 @@ +package com.ject.studytrip.member.application.facade; + +import com.ject.studytrip.member.application.dto.MemberDetail; +import com.ject.studytrip.member.application.dto.MemberInfo; +import com.ject.studytrip.member.application.service.MemberService; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import com.ject.studytrip.studylog.application.service.StudyLogService; +import com.ject.studytrip.trip.application.dto.TripCount; +import com.ject.studytrip.trip.application.service.TripService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MemberFacade { + private final MemberService memberService; + private final TripService tripService; + private final StudyLogService studyLogService; + + public void updateNicknameAndCategoryIfPresent(Long memberId, UpdateMemberRequest request) { + Member member = memberService.getActiveMemberById(memberId); + + memberService.updateNicknameAndCategoryIfPresent(member, request); + } + + public void deleteMember(Long memberId) { + Member member = memberService.getActiveMemberById(memberId); + + memberService.deleteMember(member); + } + + public MemberDetail getMemberDetail(Long memberId) { + Member member = memberService.getActiveMemberById(memberId); + TripCount tripCount = tripService.getActiveTripCountsByMemberId(memberId); + long studyLogCount = studyLogService.getActiveStudyLogCountByMemberId(memberId); + + MemberInfo memberInfo = MemberInfo.from(member); + + return MemberDetail.from(memberInfo, tripCount, studyLogCount); + } +} diff --git a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java index a4dae22..2fb626a 100644 --- a/src/main/java/com/ject/studytrip/member/application/service/MemberService.java +++ b/src/main/java/com/ject/studytrip/member/application/service/MemberService.java @@ -1,6 +1,9 @@ package com.ject.studytrip.member.application.service; +import static org.springframework.util.StringUtils.hasText; + import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.domain.error.MemberErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberCategory; @@ -8,6 +11,7 @@ import com.ject.studytrip.member.domain.policy.MemberPolicy; import com.ject.studytrip.member.domain.repository.MemberRepository; import com.ject.studytrip.member.factory.MemberFactory; +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +21,34 @@ public class MemberService { private final MemberRepository memberRepository; + @Transactional + public Member createMemberFromKakao(CreateMemberCommand command) { + validateMemberIsUnique(SocialProvider.KAKAO, command.socialId()); + + MemberCategory memberCategory = convertToMemberCategory(command.category()); + Member member = + MemberFactory.createFromKakao( + command.socialId(), + command.email(), + command.profileImage(), + command.nickname(), + memberCategory); + + return memberRepository.save(member); + } + + @Transactional + public void updateNicknameAndCategoryIfPresent(Member member, UpdateMemberRequest request) { + MemberCategory memberCategory = convertToMemberCategory(request.category()); + + member.update(request.nickname(), memberCategory); + } + + @Transactional + public void deleteMember(Member member) { + member.updateDeletedAt(); + } + @Transactional(readOnly = true) public Member getMember(Long memberId) { return memberRepository @@ -32,18 +64,20 @@ public Member getMemberBySocialProviderAndSocialId( .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NEED_SIGNUP)); } - @Transactional - public Member createMemberFromKakao( - String kakaoId, String email, String profileImage, String category, String nickname) { - boolean exists = - memberRepository.existsBySocialProviderAndSocialId(SocialProvider.KAKAO, kakaoId); - MemberPolicy.validateNickname(nickname); - MemberPolicy.validateNewMember(exists); - - MemberCategory memberCategory = MemberCategory.from(category); - Member member = - MemberFactory.fromKakao(kakaoId, email, profileImage, nickname, memberCategory); + @Transactional(readOnly = true) + public Member getActiveMemberById(Long memberId) { + return memberRepository + .findByIdAndDeletedAtIsNull(memberId) + .orElseThrow(() -> new CustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + } - return memberRepository.save(member); + private void validateMemberIsUnique(SocialProvider socialProvider, String socialId) { + boolean isMemberDuplicated = + memberRepository.existsBySocialProviderAndSocialId(socialProvider, socialId); + MemberPolicy.validateNotDuplicated(isMemberDuplicated); + } + + private MemberCategory convertToMemberCategory(String categoryName) { + return hasText(categoryName) ? MemberCategory.from(categoryName) : null; } } diff --git a/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java b/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java index 2dea116..b81eeed 100644 --- a/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java +++ b/src/main/java/com/ject/studytrip/member/domain/error/MemberErrorCode.java @@ -6,12 +6,18 @@ @RequiredArgsConstructor public enum MemberErrorCode implements ErrorCode { - MEMBER_CATEGORY_REQUIRED(HttpStatus.BAD_REQUEST, "멤버 카테고리는 필수입니다."), - MEMBER_NICKNAME_REQUIRED(HttpStatus.BAD_REQUEST, "멤버 닉네임은 필수입니다."), + // 400 + INVALID_MEMBER_CATEGORY(HttpStatus.BAD_REQUEST, "유효하지 않은 멤버 카테고리입니다."), + MEMBER_NICKNAME_DUPLICATED(HttpStatus.BAD_REQUEST, "이미 사용 중인 닉네임입니다."), + MEMBER_ALREADY_DELETED(HttpStatus.BAD_REQUEST, "해당 멤버는 이미 삭제되었습니다."), + + // 404 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "멤버를 찾을 수 없습니다."), + + // 409 MEMBER_NEED_SIGNUP(HttpStatus.CONFLICT, "회원가입이 필요한 사용자입니다."), MEMBER_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 가입된 사용자입니다."), - INVALID_MEMBER_CATEGORY(HttpStatus.BAD_REQUEST, "유효하지 않은 멤버 카테고리입니다."); + ; private final HttpStatus status; private final String message; diff --git a/src/main/java/com/ject/studytrip/member/domain/model/Member.java b/src/main/java/com/ject/studytrip/member/domain/model/Member.java index f26b40e..cfd34bf 100644 --- a/src/main/java/com/ject/studytrip/member/domain/model/Member.java +++ b/src/main/java/com/ject/studytrip/member/domain/model/Member.java @@ -1,7 +1,10 @@ package com.ject.studytrip.member.domain.model; +import static org.springframework.util.StringUtils.hasText; + import com.ject.studytrip.global.common.entity.BaseTimeEntity; import jakarta.persistence.*; +import java.time.LocalDateTime; import lombok.*; @Entity @@ -25,7 +28,7 @@ public class Member extends BaseTimeEntity { @Column(nullable = false, unique = true) private String email; - @Column(nullable = false, unique = true) + @Column(nullable = false) private String nickname; private String profileImage; @@ -54,4 +57,18 @@ public static Member of( .role(role) .build(); } + + // 프로필 이미지 수정 로직 추가 예정 + public void update(String nickname, MemberCategory category) { + if (hasText(nickname) && !nickname.equals(this.nickname)) { // 다른 경우에만 닉네임 수정 + this.nickname = nickname; + } + if (category != null && category != this.category) { // 다른 경우에만 카테고리 수정 + this.category = category; + } + } + + public void updateDeletedAt() { + this.deletedAt = LocalDateTime.now(); + } } diff --git a/src/main/java/com/ject/studytrip/member/domain/model/MemberCategory.java b/src/main/java/com/ject/studytrip/member/domain/model/MemberCategory.java index d9eedec..c5b6d99 100644 --- a/src/main/java/com/ject/studytrip/member/domain/model/MemberCategory.java +++ b/src/main/java/com/ject/studytrip/member/domain/model/MemberCategory.java @@ -1,7 +1,5 @@ package com.ject.studytrip.member.domain.model; -import static org.springframework.util.StringUtils.hasText; - import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.error.MemberErrorCode; @@ -13,10 +11,6 @@ public enum MemberCategory { ; public static MemberCategory from(String category) { - if (!hasText(category)) { - throw new CustomException(MemberErrorCode.MEMBER_CATEGORY_REQUIRED); - } - try { return MemberCategory.valueOf(category); } catch (IllegalArgumentException e) { diff --git a/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java b/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java index a700ae6..1633648 100644 --- a/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java +++ b/src/main/java/com/ject/studytrip/member/domain/policy/MemberPolicy.java @@ -1,24 +1,22 @@ package com.ject.studytrip.member.domain.policy; -import static io.jsonwebtoken.lang.Strings.hasText; - import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.model.Member; import lombok.AccessLevel; import lombok.NoArgsConstructor; @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MemberPolicy { - - public static void validateNickname(String nickname) { - if (!hasText(nickname)) { - throw new CustomException(MemberErrorCode.MEMBER_NICKNAME_REQUIRED); + public static void validateNotDuplicated(boolean exists) { + if (exists) { + throw new CustomException(MemberErrorCode.MEMBER_ALREADY_EXISTS); } } - public static void validateNewMember(boolean exists) { - if (exists) { - throw new CustomException(MemberErrorCode.MEMBER_ALREADY_EXISTS); + public static void validateNotDeleted(Member member) { + if (member.getDeletedAt() != null) { + throw new CustomException(MemberErrorCode.MEMBER_ALREADY_DELETED); } } } diff --git a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java index 194ea54..ac2991b 100644 --- a/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java +++ b/src/main/java/com/ject/studytrip/member/domain/repository/MemberRepository.java @@ -13,4 +13,6 @@ Optional findBySocialProviderAndSocialId( Optional findById(Long id); Member save(Member member); + + Optional findByIdAndDeletedAtIsNull(Long id); } diff --git a/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java b/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java index 53f2d3c..0f3aacd 100644 --- a/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java +++ b/src/main/java/com/ject/studytrip/member/factory/MemberFactory.java @@ -9,7 +9,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class MemberFactory { - public static Member fromKakao( + public static Member createFromKakao( String kakaoId, String email, String profileImage, diff --git a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java index 32083f2..e075b0e 100644 --- a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java +++ b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberJpaRepository.java @@ -10,4 +10,6 @@ Optional findBySocialProviderAndSocialId( SocialProvider socialProvider, String socialId); boolean existsBySocialProviderAndSocialId(SocialProvider socialProvider, String socialId); + + Optional findByIdAndDeletedAtIsNull(Long id); } diff --git a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java index 77b4a96..3aa73c3 100644 --- a/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/member/infra/jpa/MemberRepositoryAdapter.java @@ -33,4 +33,9 @@ public Optional findById(Long id) { public Member save(Member member) { return memberJpaRepository.save(member); } + + @Override + public Optional findByIdAndDeletedAtIsNull(Long id) { + return memberJpaRepository.findByIdAndDeletedAtIsNull(id); + } } diff --git a/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java new file mode 100644 index 0000000..fed4775 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/controller/MemberController.java @@ -0,0 +1,59 @@ +package com.ject.studytrip.member.presentation.controller; + +import com.ject.studytrip.global.common.response.StandardResponse; +import com.ject.studytrip.member.application.dto.MemberDetail; +import com.ject.studytrip.member.application.facade.MemberFacade; +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import com.ject.studytrip.member.presentation.dto.response.LoadMemberDetailResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +@Tag(name = "Member", description = "멤버 API") +@RequestMapping("/api/members") +@RestController +@RequiredArgsConstructor +@Validated +public class MemberController { + private final MemberFacade memberFacade; + + @Operation(summary = "멤버 수정", description = "멤버의 이름 또는 카테고리를 수정합니다.") + @PatchMapping("/me") + public ResponseEntity updateMember( + @AuthenticationPrincipal String memberId, @RequestBody UpdateMemberRequest request) { + memberFacade.updateNicknameAndCategoryIfPresent(Long.valueOf(memberId), request); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "멤버 삭제", description = "멤버를 삭제합니다. (회원탈퇴)") + @DeleteMapping("/me") + public ResponseEntity deleteMember(@AuthenticationPrincipal String memberId) { + memberFacade.deleteMember(Long.valueOf(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body(StandardResponse.success(HttpStatus.OK.value(), null)); + } + + @Operation(summary = "멤버 상세 조회", description = "멤버를 상세 조회합니다.") + @GetMapping("/me") + public ResponseEntity loadMemberDetail( + @AuthenticationPrincipal String memberId) { + MemberDetail result = memberFacade.getMemberDetail(Long.valueOf(memberId)); + + return ResponseEntity.status(HttpStatus.OK) + .body( + StandardResponse.success( + HttpStatus.OK.value(), + LoadMemberDetailResponse.of( + result.memberInfo(), + result.tripCount(), + result.studyLogCount()))); + } +} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java b/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java new file mode 100644 index 0000000..0c0fdfc --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/request/UpdateMemberRequest.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.member.presentation.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Pattern; + +public record UpdateMemberRequest( + @Schema(description = "수정할 멤버 닉네임") + @Pattern( + regexp = "^[a-zA-Z0-9가-힣]{2,10}$", + message = "닉네임은 특수문자를 제외하고 2~10자 이내로 입력해주세요.") + String nickname, + @Schema(description = "수정할 멤버 카테고리") + @Pattern( + regexp = "^(STUDENT|WORKER|FREELANCER|JOBSEEKER)$", + message = "멤버 카테고리는 STUDENT, WORKER, FREELANCER, JOBSEEKER 중 하나여야 합니다.") + String category) {} diff --git a/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java b/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java new file mode 100644 index 0000000..bb72fb5 --- /dev/null +++ b/src/main/java/com/ject/studytrip/member/presentation/dto/response/LoadMemberDetailResponse.java @@ -0,0 +1,29 @@ +package com.ject.studytrip.member.presentation.dto.response; + +import com.ject.studytrip.member.application.dto.MemberInfo; +import com.ject.studytrip.member.domain.model.MemberCategory; +import com.ject.studytrip.trip.application.dto.TripCount; +import io.swagger.v3.oas.annotations.media.Schema; + +public record LoadMemberDetailResponse( + @Schema(description = "멤버 ID") Long memberId, + @Schema(description = "이메일") String email, + @Schema(description = "닉네임") String nickname, + @Schema(description = "프로필 이미지") String profileImage, + @Schema(description = "멤버 카테고리") MemberCategory category, + @Schema(description = "코스형 여행 개수") long courseTripCount, + @Schema(description = "탐험형 여행 개수") long exploreTripCount, + @Schema(description = "학습 기록 개수") long studyLogCount) { + public static LoadMemberDetailResponse of( + MemberInfo memberInfo, TripCount tripCount, long studyLogCount) { + return new LoadMemberDetailResponse( + memberInfo.memberId(), + memberInfo.email(), + memberInfo.nickname(), + memberInfo.profileImage(), + memberInfo.category(), + tripCount.course(), + tripCount.explore(), + studyLogCount); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java new file mode 100644 index 0000000..9c253a2 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/application/service/StudyLogService.java @@ -0,0 +1,17 @@ +package com.ject.studytrip.studylog.application.service; + +import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class StudyLogService { + private final StudyLogQueryRepository studyLogQueryRepository; + + @Transactional(readOnly = true) + public long getActiveStudyLogCountByMemberId(Long memberId) { + return studyLogQueryRepository.countActiveStudyLogsByMemberId(memberId); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java b/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java new file mode 100644 index 0000000..e05448c --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/factory/StudyLogFactory.java @@ -0,0 +1,15 @@ +package com.ject.studytrip.studylog.domain.factory; + +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.studylog.domain.model.StudyLog; +import com.ject.studytrip.trip.domain.model.DailyGoal; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class StudyLogFactory { + public static StudyLog create( + Member member, DailyGoal dailyGoal, String title, String content) { + return StudyLog.of(member, dailyGoal, title, content); + } +} diff --git a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java index cc20a41..350acde 100644 --- a/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java +++ b/src/main/java/com/ject/studytrip/studylog/domain/model/StudyLog.java @@ -1,6 +1,7 @@ package com.ject.studytrip.studylog.domain.model; import com.ject.studytrip.global.common.entity.BaseTimeEntity; +import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.trip.domain.model.DailyGoal; import jakarta.persistence.*; import lombok.*; @@ -16,6 +17,10 @@ public class StudyLog extends BaseTimeEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "daily_goal_id", nullable = false) private DailyGoal dailyGoal; @@ -26,7 +31,12 @@ public class StudyLog extends BaseTimeEntity { @Column(nullable = false) private String content; - public static StudyLog of(DailyGoal dailyGoal, String title, String content) { - return StudyLog.builder().dailyGoal(dailyGoal).title(title).content(content).build(); + public static StudyLog of(Member member, DailyGoal dailyGoal, String title, String content) { + return StudyLog.builder() + .member(member) + .dailyGoal(dailyGoal) + .title(title) + .content(content) + .build(); } } diff --git a/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java new file mode 100644 index 0000000..39e876d --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/domain/repository/StudyLogQueryRepository.java @@ -0,0 +1,5 @@ +package com.ject.studytrip.studylog.domain.repository; + +public interface StudyLogQueryRepository { + long countActiveStudyLogsByMemberId(Long memberId); +} diff --git a/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java new file mode 100644 index 0000000..3654222 --- /dev/null +++ b/src/main/java/com/ject/studytrip/studylog/infra/querydsl/StudyLogQueryRepositoryAdapter.java @@ -0,0 +1,27 @@ +package com.ject.studytrip.studylog.infra.querydsl; + +import com.ject.studytrip.studylog.domain.model.QStudyLog; +import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class StudyLogQueryRepositoryAdapter implements StudyLogQueryRepository { + private final JPAQueryFactory queryFactory; + private final QStudyLog studyLog = QStudyLog.studyLog; + + @Override + public long countActiveStudyLogsByMemberId(Long memberId) { + Long count = + queryFactory + .select(studyLog.count()) + .from(studyLog) + .where(studyLog.member.id.eq(memberId).and(studyLog.deletedAt.isNull())) + .fetchOne(); + + return Optional.ofNullable(count).orElse(0L); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java b/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java new file mode 100644 index 0000000..c5d5082 --- /dev/null +++ b/src/main/java/com/ject/studytrip/trip/application/dto/TripCount.java @@ -0,0 +1,7 @@ +package com.ject.studytrip.trip.application.dto; + +public record TripCount(long course, long explore) { + public static TripCount of(long course, long explore) { + return new TripCount(course, explore); + } +} diff --git a/src/main/java/com/ject/studytrip/trip/application/service/TripService.java b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java index 1ddb70c..2196cc1 100644 --- a/src/main/java/com/ject/studytrip/trip/application/service/TripService.java +++ b/src/main/java/com/ject/studytrip/trip/application/service/TripService.java @@ -2,6 +2,7 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.factory.TripFactory; import com.ject.studytrip.trip.domain.model.Trip; @@ -16,6 +17,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @@ -85,4 +87,15 @@ public Trip getValidTrip(Long memberId, Long tripId) { public Slice getTripsSliceByMemberId(Long memberId, int page, int size) { return tripQueryRepository.findSliceByMemberId(memberId, PageRequest.of(page, size)); } + + @Transactional(readOnly = true) + public TripCount getActiveTripCountsByMemberId(Long memberId) { + long courseCount = + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + memberId, TripCategory.COURSE); + long exploreCount = + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + memberId, TripCategory.EXPLORE); + return TripCount.of(courseCount, exploreCount); + } } diff --git a/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java index d980226..6af0e5f 100644 --- a/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java +++ b/src/main/java/com/ject/studytrip/trip/domain/repository/TripQueryRepository.java @@ -1,9 +1,12 @@ package com.ject.studytrip.trip.domain.repository; import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; public interface TripQueryRepository { Slice findSliceByMemberId(Long memberId, Pageable pageable); + + long countActiveTripsByMemberIdAndCategory(Long memberId, TripCategory category); } diff --git a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java index 2a23d8a..7edb648 100644 --- a/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java +++ b/src/main/java/com/ject/studytrip/trip/infra/querydsl/TripQueryRepositoryAdapter.java @@ -2,9 +2,11 @@ import com.ject.studytrip.trip.domain.model.QTrip; import com.ject.studytrip.trip.domain.model.Trip; +import com.ject.studytrip.trip.domain.model.TripCategory; import com.ject.studytrip.trip.domain.repository.TripQueryRepository; import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; @@ -35,4 +37,19 @@ public Slice findSliceByMemberId(Long memberId, Pageable pageable) { return new SliceImpl<>(result, pageable, hasNext); } + + @Override + public long countActiveTripsByMemberIdAndCategory(Long memberId, TripCategory category) { + Long count = + queryFactory + .select(trip.count()) + .from(trip) + .where( + trip.member.id.eq(memberId), + trip.deletedAt.isNull(), + trip.category.eq(category)) + .fetchOne(); + + return Optional.ofNullable(count).orElse(0L); + } } diff --git a/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java b/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java index 3c5a2ca..8f2fcb1 100644 --- a/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java +++ b/src/test/java/com/ject/studytrip/auth/application/service/KakaoLoginServiceTest.java @@ -6,7 +6,8 @@ import com.ject.studytrip.BaseUnitTest; import com.ject.studytrip.auth.domain.error.AuthErrorCode; -import com.ject.studytrip.auth.fixture.KakaoOauthFixture; +import com.ject.studytrip.auth.fixture.KakaoTokenResponseFixture; +import com.ject.studytrip.auth.fixture.KakaoUserInfoResponseFixture; import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider; @@ -55,7 +56,7 @@ void shouldThrowExceptionWhenAuthorizationCodeIsInvalid() { @DisplayName("카카오 토큰 응답은 왔지만 사용자 정보 조회에 실패하면 예외가 발생한다.") void shouldThrowExceptionWhenFetchingKakaoUserInfoFails() { // given - KakaoTokenResponse tokenResponse = KakaoOauthFixture.createTokenResponse(); + KakaoTokenResponse tokenResponse = new KakaoTokenResponseFixture().build(); when(kakaoOauthProvider.getKakaoTokens(VALID_CODE)).thenReturn(tokenResponse); when(kakaoOauthProvider.getKakaoUserInfo(tokenResponse.accessToken())) .thenThrow(new CustomException(AuthErrorCode.KAKAO_USER_INFO_FETCH_FAILED)); @@ -70,9 +71,9 @@ void shouldThrowExceptionWhenFetchingKakaoUserInfoFails() { @DisplayName("유효한 인가 코드를 전달하면 사용자 정보를 반환한다.") void shouldReturnKakaoUserInfoResponseWhenCodeIsValid() { // given - KakaoTokenResponse kakaoTokenResponse = KakaoOauthFixture.createTokenResponse(); + KakaoTokenResponse kakaoTokenResponse = new KakaoTokenResponseFixture().build(); KakaoUserInfoResponse kakaoUserInfoResponse = - KakaoOauthFixture.createKakaoUserInfoResponse(); + new KakaoUserInfoResponseFixture().build(); when(kakaoOauthProvider.getKakaoTokens(VALID_CODE)).thenReturn(kakaoTokenResponse); when(kakaoOauthProvider.getKakaoUserInfo("access-token")) .thenReturn(kakaoUserInfoResponse); diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java new file mode 100644 index 0000000..820e65b --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoLoginRequestFixture.java @@ -0,0 +1,16 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; + +public class KakaoLoginRequestFixture { + private String code = "valid-code"; + + public KakaoLoginRequestFixture withCode(String code) { + this.code = code; + return this; + } + + public KakaoLoginRequest build() { + return new KakaoLoginRequest(code); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java deleted file mode 100644 index 216ead7..0000000 --- a/src/test/java/com/ject/studytrip/auth/fixture/KakaoOauthFixture.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.ject.studytrip.auth.fixture; - -import com.ject.studytrip.auth.infra.dto.KakaoAccount; -import com.ject.studytrip.auth.infra.dto.KakaoProfile; -import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; -import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; -import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; -import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; - -public class KakaoOauthFixture { - private static final String KAKAO_ID = "12345"; - private static final String EMAIL = "choi@kakao.com"; - private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; - private static final String VALID_CODE = "valid-code"; - private static final String NICKNAME = "민우"; - private static final String CATEGORY = "STUDENT"; - - public static KakaoUserInfoResponse createKakaoUserInfoResponse() { - return new KakaoUserInfoResponse( - KAKAO_ID, new KakaoAccount(new KakaoProfile(PROFILE_IMAGE), EMAIL)); - } - - public static KakaoLoginRequest createLoginRequest() { - return new KakaoLoginRequest(VALID_CODE); - } - - public static KakaoSignupRequest createSignupRequest() { - return new KakaoSignupRequest(VALID_CODE, CATEGORY, NICKNAME); - } - - public static KakaoTokenResponse createTokenResponse() { - return new KakaoTokenResponse( - "bearer", "access-token", 3600, "refresh-token", 7200, "scope"); - } -} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java new file mode 100644 index 0000000..1c96359 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoSignupRequestFixture.java @@ -0,0 +1,28 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; + +public class KakaoSignupRequestFixture { + private String code = "valid-code"; + private String category = "STUDENT"; + private String nickname = "민우"; + + public KakaoSignupRequestFixture withCode(String code) { + this.code = code; + return this; + } + + public KakaoSignupRequestFixture withCategory(String category) { + this.category = category; + return this; + } + + public KakaoSignupRequestFixture withNickname(String nickname) { + this.nickname = nickname; + return this; + } + + public KakaoSignupRequest build() { + return new KakaoSignupRequest(code, category, nickname); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java new file mode 100644 index 0000000..e945609 --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoTokenResponseFixture.java @@ -0,0 +1,11 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; + +public class KakaoTokenResponseFixture { + + public KakaoTokenResponse build() { + return new KakaoTokenResponse( + "bearer", "access-token", 3600, "refresh-token", 7200, "scope"); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java b/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java new file mode 100644 index 0000000..a0df5be --- /dev/null +++ b/src/test/java/com/ject/studytrip/auth/fixture/KakaoUserInfoResponseFixture.java @@ -0,0 +1,37 @@ +package com.ject.studytrip.auth.fixture; + +import com.ject.studytrip.auth.infra.dto.KakaoAccount; +import com.ject.studytrip.auth.infra.dto.KakaoProfile; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; + +public class KakaoUserInfoResponseFixture { + private String kakaoId = "12345"; + private String email = "choi@kakao.com"; + private String profileImage = "https://kakao.com/profile.jpg"; + + public KakaoUserInfoResponseFixture withKakaoId(String kakaoId) { + this.kakaoId = kakaoId; + return this; + } + + public KakaoUserInfoResponseFixture withEmail(String email) { + this.email = email; + return this; + } + + public KakaoUserInfoResponseFixture withProfileImage(String profileImage) { + this.profileImage = profileImage; + return this; + } + + public KakaoUserInfoResponseFixture withKakaoIdAndEmail(String kakaoId, String email) { + this.kakaoId = kakaoId; + this.email = email; + return this; + } + + public KakaoUserInfoResponse build() { + return new KakaoUserInfoResponse( + kakaoId, new KakaoAccount(new KakaoProfile(profileImage), email)); + } +} diff --git a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java index 6df883c..b057651 100644 --- a/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/auth/presentation/controller/AuthControllerIntegrationTest.java @@ -6,7 +6,9 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; import com.ject.studytrip.BaseIntegrationTest; -import com.ject.studytrip.auth.fixture.KakaoOauthFixture; +import com.ject.studytrip.auth.fixture.*; +import com.ject.studytrip.auth.infra.dto.KakaoTokenResponse; +import com.ject.studytrip.auth.infra.dto.KakaoUserInfoResponse; import com.ject.studytrip.auth.infra.provider.KakaoOauthProvider; import com.ject.studytrip.auth.presentation.dto.request.KakaoLoginRequest; import com.ject.studytrip.auth.presentation.dto.request.KakaoSignupRequest; @@ -17,6 +19,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; @@ -29,28 +32,39 @@ class AuthControllerIntegrationTest extends BaseIntegrationTest { @MockitoBean KakaoOauthProvider kakaoOauthProvider; @Nested - @DisplayName("kakaoLogin 메서드는") + @DisplayName("카카오 로그인 API") class KakaoLogin { + private final KakaoTokenResponseFixture kakaoTokenResponseFixture = + new KakaoTokenResponseFixture(); + private final KakaoLoginRequestFixture kakaoLoginRequestFixture = + new KakaoLoginRequestFixture(); + private final KakaoUserInfoResponseFixture kakaoUserInfoResponseFixture = + new KakaoUserInfoResponseFixture(); + + private ResultActions getResultActions(KakaoLoginRequest request) throws Exception { + return mockMvc.perform( + post("/api/auth/login/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } @Test - @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 MEMBER_NEED_SIGNUP 예외가 발생한다") - void shouldThrowExceptionWhenMemberNotSignUp() throws Exception { + @DisplayName("가입되지 않은 사용자 인가 코드로 로그인 시 409 Conflict를 반환한다.") + void shouldReturnConflictWhenMemberNotSignUp() throws Exception { // given - KakaoLoginRequest request = KakaoOauthFixture.createLoginRequest(); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); + KakaoLoginRequest request = kakaoLoginRequestFixture.build(); + KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); + + given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) .willThrow(new CustomException(MemberErrorCode.MEMBER_NEED_SIGNUP)); // when - ResultActions result = - mockMvc.perform( - post("/api/auth/login/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions resultActions = getResultActions(request); // then - result.andExpect(status().isConflict()) + resultActions + .andExpect(status().isConflict()) .andExpect(jsonPath("$.success").value(false)) .andExpect( jsonPath("$.status") @@ -64,46 +78,60 @@ void shouldThrowExceptionWhenMemberNotSignUp() throws Exception { } @Test - @DisplayName("가입된 사용자의 인가 코드로 로그인하면 토큰이 발급된다") + @DisplayName("가입된 사용자의 인가 코드로 로그인하면 토큰이 발급된다.") void shouldReturnTokenResponseWhenLoginIsSuccessful() throws Exception { // given - KakaoLoginRequest request = KakaoOauthFixture.createLoginRequest(); + KakaoLoginRequest request = kakaoLoginRequestFixture.build(); + KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); + KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); + memberTestHelper.saveMember(); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + .willReturn(kakaoUserInfoResponse); // when - ResultActions result = - mockMvc.perform( - post("/api/auth/login/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions resultActions = getResultActions(request); // then - result.andExpect(status().isOk()) + resultActions + .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); } } @Nested - @DisplayName("kakaoSignup 메서드는") + @DisplayName("카카오 회원가입 API") class KakaoSignup { + private final KakaoTokenResponseFixture kakaoTokenResponseFixture = + new KakaoTokenResponseFixture(); + private final KakaoSignupRequestFixture kakaoSignupRequestFixture = + new KakaoSignupRequestFixture(); + private final KakaoUserInfoResponseFixture kakaoUserInfoResponseFixture = + new KakaoUserInfoResponseFixture(); + + private ResultActions getResultActions(KakaoSignupRequest request) throws Exception { + return mockMvc.perform( + post("/api/auth/signup/kakao") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } @Test - @DisplayName("이미 가입된 사용자가 회원가입 요청 시 MEMBER_ALREADY_EXISTS 예외가 발생한다") + @DisplayName("이미 가입된 사용자가 회원가입 요청 시 409 Conflict를 반환한다.") void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { // given - KakaoSignupRequest request = KakaoOauthFixture.createSignupRequest(); + KakaoSignupRequest request = kakaoSignupRequestFixture.build(); + KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); + KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); + memberTestHelper.saveMember(); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); + given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + .willReturn(kakaoUserInfoResponse); // when ResultActions result = @@ -127,81 +155,25 @@ void shouldThrowExceptionWhenSignupForExistingMember() throws Exception { } @Test - @DisplayName("회원가입 요청 시 category가 유효하지 않으면 MEMBER_CATEGORY_REQUIRED 예외가 발생한다") - void shouldThrowExceptionWhenCategoryIsInvalid() throws Exception { - // given - KakaoSignupRequest request = new KakaoSignupRequest("valid-code", "", "민우"); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); - given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); - - // when - ResultActions result = - mockMvc.perform( - post("/api/auth/signup/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - // then - result.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - MemberErrorCode.MEMBER_CATEGORY_REQUIRED - .getStatus() - .value())); - } - - @Test - @DisplayName("회원가입 요청 시 닉네임이 비어있으면 MEMBER_NICKNAME_REQUIRED 예외가 발생한다") - void shouldThrowExceptionWhenSignupNicknameIsBlank() throws Exception { - // given - KakaoSignupRequest request = new KakaoSignupRequest("valid-code", "STUDENT", ""); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); - given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); - - // when - ResultActions result = - mockMvc.perform( - post("/api/auth/signup/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); - - // then - result.andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.success").value(false)) - .andExpect( - jsonPath("$.status") - .value( - MemberErrorCode.MEMBER_NICKNAME_REQUIRED - .getStatus() - .value())); - } - - @Test - @DisplayName("회원가입 요청 시 유효한 정보라면 토큰이 발급된다") + @DisplayName("회원가입 요청 시 유효한 정보라면 토큰이 발급된다.") void shouldReturnTokenResponseWhenSignupIsSuccessful() throws Exception { // given - KakaoSignupRequest request = KakaoOauthFixture.createSignupRequest(); - given(kakaoOauthProvider.getKakaoTokens(anyString())) - .willReturn(KakaoOauthFixture.createTokenResponse()); + KakaoSignupRequest request = kakaoSignupRequestFixture.build(); + KakaoTokenResponse kakaoTokenResponse = kakaoTokenResponseFixture.build(); + KakaoUserInfoResponse kakaoUserInfoResponse = kakaoUserInfoResponseFixture.build(); + + given(kakaoOauthProvider.getKakaoTokens(anyString())).willReturn(kakaoTokenResponse); given(kakaoOauthProvider.getKakaoUserInfo(anyString())) - .willReturn(KakaoOauthFixture.createKakaoUserInfoResponse()); + .willReturn(kakaoUserInfoResponse); // when - ResultActions result = - mockMvc.perform( - post("/api/auth/signup/kakao") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(request))); + ResultActions resultActions = getResultActions(request); // then - result.andExpect(status().isOk()) + resultActions + .andExpect(status().isOk()) .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) .andExpect(jsonPath("$.data.accessToken").isNotEmpty()) .andExpect(jsonPath("$.data.refreshToken").isNotEmpty()); } diff --git a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java index b0d482b..3081708 100644 --- a/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java +++ b/src/test/java/com/ject/studytrip/member/application/service/MemberServiceTest.java @@ -1,15 +1,22 @@ package com.ject.studytrip.member.application.service; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import com.ject.studytrip.BaseUnitTest; import com.ject.studytrip.global.exception.CustomException; +import com.ject.studytrip.member.application.dto.CreateMemberCommand; import com.ject.studytrip.member.domain.error.MemberErrorCode; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.SocialProvider; import com.ject.studytrip.member.domain.repository.MemberRepository; +import com.ject.studytrip.member.fixture.CreateMemberCommandFixture; import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture; +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import java.time.LocalDateTime; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -17,14 +24,12 @@ import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; @DisplayName("MemberService 단위 테스트") class MemberServiceTest extends BaseUnitTest { - private static final String KAKAO_ID = "12345"; - private static final String EMAIL = "choi@kakao.com"; - private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; - private static final String NICKNAME = "민우"; - private static final String CATEGORY = "STUDENT"; + private static final String NEW_MEMBER_NICKNAME = "팬텀"; + private static final String NEW_MEMBER_CATEGORY = "WORKER"; @InjectMocks private MemberService memberService; @Mock private MemberRepository memberRepository; @@ -32,10 +37,188 @@ class MemberServiceTest extends BaseUnitTest { private Member member; private Member memberWithoutProfileImage; + private String socialId; + private String nickname; + private String category; + @BeforeEach void setUp() { member = MemberFixture.createMemberFromKakao(); memberWithoutProfileImage = MemberFixture.createMemberWithoutProfileImageFromKakao(); + + socialId = member.getSocialId(); + nickname = member.getNickname(); + category = member.getCategory().name(); + } + + @Nested + @DisplayName("createMemberFromKakao 메서드는") + class CreateMemberFromKakao { + private final CreateMemberCommandFixture fixture = new CreateMemberCommandFixture(); + + @Test + @DisplayName("이미 가입된 멤버가 존재하면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberAlreadyExists() { + // given + CreateMemberCommand command = fixture.withNickname(nickname).build(); + given( + memberRepository.existsBySocialProviderAndSocialId( + SocialProvider.KAKAO, socialId)) + .willReturn(true); + + // when & then + assertThatThrownBy(() -> memberService.createMemberFromKakao(command)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_ALREADY_EXISTS.getMessage()); + } + + @Test + @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenCategoryIsInValid() { + // given + CreateMemberCommand command = fixture.withCategory("INVALID").build(); + given( + memberRepository.existsBySocialProviderAndSocialId( + SocialProvider.KAKAO, socialId)) + .willReturn(false); + + // when & then + assertThatThrownBy(() -> memberService.createMemberFromKakao(command)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.INVALID_MEMBER_CATEGORY.getMessage()); + } + + @Test + @DisplayName("CreateMemberCommand가 유효하면 Member를 생성하고 반환한다.") + void shouldReturnMemberWhenCommandIsValid() { + // given + CreateMemberCommand command = fixture.build(); + given( + memberRepository.existsBySocialProviderAndSocialId( + SocialProvider.KAKAO, socialId)) + .willReturn(false); + given(memberRepository.save(any(Member.class))).willReturn(member); + + // when + Member result = memberService.createMemberFromKakao(command); + + // then + assertThat(result).isEqualTo(member); + } + + @Test + @DisplayName("프로필 이미지가 없어도 Member를 생성하고 반환한다.") + void shouldReturnMemberWhenProfileImageIsNull() { + // given + CreateMemberCommand command = fixture.withProfileImage(null).build(); + given( + memberRepository.existsBySocialProviderAndSocialId( + SocialProvider.KAKAO, socialId)) + .willReturn(false); + given(memberRepository.save(any(Member.class))).willReturn(memberWithoutProfileImage); + + // when + Member result = memberService.createMemberFromKakao(command); + + // then + assertThat(result).isEqualTo(memberWithoutProfileImage); + } + } + + @Nested + @DisplayName("updateNicknameAndCategoryIfPresent 메서드는") + class UpdateNicknameAndCategoryIfPresent { + private final UpdateMemberRequestFixture fixture = new UpdateMemberRequestFixture(); + + @Test + @DisplayName("카테고리가 유효하지 않으면 예외가 발생한다.") + void shouldThrowExceptionWhenCategoryIsInValid() { + // given + UpdateMemberRequest request = fixture.withCategory("INVALID").build(); + + // when & then + assertThatThrownBy( + () -> memberService.updateNicknameAndCategoryIfPresent(member, request)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.INVALID_MEMBER_CATEGORY.getMessage()); + } + + @Test + @DisplayName("특정 멤버의 닉네임만 수정하고 DB에 반영한다.") + void shouldUpdateMemberNickname() { + // given + UpdateMemberRequest request = fixture.withNickname(NEW_MEMBER_NICKNAME).build(); + + // when + memberService.updateNicknameAndCategoryIfPresent(member, request); + + // then + assertThat(member.getNickname()).isEqualTo(NEW_MEMBER_NICKNAME); + assertThat(member.getCategory().name()).isEqualTo(category); + } + + @Test + @DisplayName("특정 멤버의 카테고리만 수정하고 DB에 반영한다.") + void shouldUpdateMemberCategory() { + // given + UpdateMemberRequest request = fixture.withCategory(NEW_MEMBER_CATEGORY).build(); + + // when + memberService.updateNicknameAndCategoryIfPresent(member, request); + + // then + assertThat(member.getNickname()).isEqualTo(nickname); + assertThat(member.getCategory().name()).isEqualTo(NEW_MEMBER_CATEGORY); + } + + @Test + @DisplayName("특정 멤버의 카테고리만 수정하고 DB에 반영한다.") + void shouldUpdateMemberNicknameAndCategory() { + // given + UpdateMemberRequest request = + fixture.withNickname(NEW_MEMBER_NICKNAME) + .withCategory(NEW_MEMBER_CATEGORY) + .build(); + + // when + memberService.updateNicknameAndCategoryIfPresent(member, request); + + // then + assertThat(member.getNickname()).isEqualTo(NEW_MEMBER_NICKNAME); + assertThat(member.getCategory().name()).isEqualTo(NEW_MEMBER_CATEGORY); + } + } + + @Nested + @DisplayName("deleteMember 메서드는") + class DeleteMember { + + // @Test + // @DisplayName("멤버가 이미 삭제된 경우 예외가 발생한다.") + // void shouldThrowExceptionWhenMemberAlreadyDeleted() { + // // given + // ReflectionTestUtils.setField(member, "deletedAt", LocalDateTime.now()); + // + // // when & then + // assertThatThrownBy(() -> memberService.deleteMember(member)) + // .isInstanceOf(CustomException.class) + // .hasMessage(MemberErrorCode.MEMBER_ALREADY_DELETED.getMessage()); + // } + + @Test + @DisplayName("멤버를 삭제하면 deletedAt 필드에 현재 시각이 설정된다.") + void shouldDeleteMember() { + // given + assertThat(member.getDeletedAt()).isNull(); + LocalDateTime beforeDeletionTime = LocalDateTime.now(); + + // when + memberService.deleteMember(member); + + // then + assertThat(member.getDeletedAt()).isNotNull(); + assertThat(member.getDeletedAt()).isAfterOrEqualTo(beforeDeletionTime); + } } @Nested @@ -43,7 +226,7 @@ void setUp() { class GetMember { @Test - @DisplayName("존재하지 않는 ID로 조회하면 예외가 발생한다.") + @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") void shouldThrowExceptionWhenMemberIdNotFound() { // given Long invalidId = -1L; @@ -56,7 +239,7 @@ void shouldThrowExceptionWhenMemberIdNotFound() { } @Test - @DisplayName("ID로 멤버를 조회하면 Member를 반환한다.") + @DisplayName("멤버 ID가 존재하면 Member를 반환한다.") void shouldReturnMemberWhenMemberIdExists() { // given given(memberRepository.findById(1L)).willReturn(Optional.of(member)); @@ -77,14 +260,14 @@ class GetMemberBySocialProviderAndSocialId { @DisplayName("소셜 ID로 조회 시 존재하지 않으면 예외가 발생한다.") void shouldThrowExceptionWhenSocialIdNotFound() { // given - given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)) .willReturn(Optional.empty()); // when & then assertThatThrownBy( () -> memberService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, KAKAO_ID)) + SocialProvider.KAKAO, socialId)) .isInstanceOf(CustomException.class) .hasMessage(MemberErrorCode.MEMBER_NEED_SIGNUP.getMessage()); } @@ -93,100 +276,64 @@ void shouldThrowExceptionWhenSocialIdNotFound() { @DisplayName("소셜 ID로 조회 시 존재하면 Member를 반환한다.") void shouldReturnMemberWhenSocialIdExists() { // given - given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, KAKAO_ID)) + given(memberRepository.findBySocialProviderAndSocialId(SocialProvider.KAKAO, socialId)) .willReturn(Optional.of(member)); // when Member result = memberService.getMemberBySocialProviderAndSocialId( - SocialProvider.KAKAO, KAKAO_ID); + SocialProvider.KAKAO, socialId); // then assertThat(result).isEqualTo(member); } - - @Test - @DisplayName("이미 존재하는 멤버라면 예외가 발생한다.") - void shouldThrowExceptionWhenMemberAlreadyExists() { - // given - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, KAKAO_ID)) - .willReturn(true); - - // when & then - assertThatThrownBy( - () -> - memberService.createMemberFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, NICKNAME)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_ALREADY_EXISTS.getMessage()); - } } @Nested - @DisplayName("createMemberFromKakao 메서드는") - class CreateMemberFromKakao { + @DisplayName("getActiveMemberById 메서드는") + class GetActiveMemberById { @Test - @DisplayName("카테고리가 비어 있으면 예외가 발생한다.") - void shouldThrowExceptionWhenCategoryIsBlank() { - // when & then - assertThatThrownBy( - () -> - memberService.createMemberFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, " ", NICKNAME)) - .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_CATEGORY_REQUIRED.getMessage()); - } + @DisplayName("존재하지 않는 멤버 ID로 조회하면 예외가 발생한다.") + void shouldThrowExceptionWhenMemberIdNotFound() { + // given + Long invalidId = -1L; + given(memberRepository.findByIdAndDeletedAtIsNull(invalidId)) + .willReturn(Optional.empty()); - @Test - @DisplayName("닉네임이 비어 있으면 예외가 발생한다.") - void shouldThrowExceptionWhenNicknameIsBlank() { // when & then - assertThatThrownBy( - () -> - memberService.createMemberFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, " ")) + assertThatThrownBy(() -> memberService.getActiveMemberById(invalidId)) .isInstanceOf(CustomException.class) - .hasMessage(MemberErrorCode.MEMBER_NICKNAME_REQUIRED.getMessage()); + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); } @Test - @DisplayName("모든 정보가 유효하면 Member를 생성하고 반환한다.") - void shouldCreateMemberWhenAllDataIsValid() { + @DisplayName("멤버가 이미 삭제된 경우 예외가 발생한다.") + void shouldThrowExceptionWhenMemberAlreadyDeleted() { // given - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, KAKAO_ID)) - .willReturn(false); - given(memberRepository.save(any(Member.class))).willReturn(member); - - // when - Member result = - memberService.createMemberFromKakao( - KAKAO_ID, EMAIL, PROFILE_IMAGE, CATEGORY, NICKNAME); + Long memberId = member.getId(); + ReflectionTestUtils.setField(member, "deletedAt", LocalDateTime.now()); - // then - assertThat(result).isEqualTo(member); + // when & then + assertThatThrownBy(() -> memberService.getActiveMemberById(memberId)) + .isInstanceOf(CustomException.class) + .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); } @Test - @DisplayName("프로필 이미지가 없어도 Member를 생성하고 반환한다.") - void shouldCreateMemberWhenProfileImageIsNull() { + @DisplayName("유효한 ID가 주어지면 Member를 반환한다.") + void shouldReturnMemberWhenIdIsValid() { // given - given( - memberRepository.existsBySocialProviderAndSocialId( - SocialProvider.KAKAO, KAKAO_ID)) - .willReturn(false); - given(memberRepository.save(any(Member.class))).willReturn(memberWithoutProfileImage); + Long memberId = member.getId(); + given(memberRepository.findByIdAndDeletedAtIsNull(memberId)) + .willReturn(Optional.of(member)); // when - Member result = - memberService.createMemberFromKakao(KAKAO_ID, EMAIL, null, CATEGORY, NICKNAME); + Member result = memberService.getActiveMemberById(memberId); // then - assertThat(result).isEqualTo(memberWithoutProfileImage); + assertThat(result).isEqualTo(member); + assertThat(result.getDeletedAt()).isNull(); } } } diff --git a/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java b/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java new file mode 100644 index 0000000..210b3dc --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/fixture/CreateMemberCommandFixture.java @@ -0,0 +1,40 @@ +package com.ject.studytrip.member.fixture; + +import com.ject.studytrip.member.application.dto.CreateMemberCommand; + +public class CreateMemberCommandFixture { + private String socialId = "12345"; + private String email = "choi@kakao.com"; + private String profileImage = "https://kakao.com/profile.jpg"; + private String nickname = "민우"; + private String category = "STUDENT"; + + public CreateMemberCommandFixture withSocialId(String socialId) { + this.socialId = socialId; + return this; + } + + public CreateMemberCommandFixture withEmail(String email) { + this.email = email; + return this; + } + + public CreateMemberCommandFixture withProfileImage(String profileImage) { + this.profileImage = profileImage; + return this; + } + + public CreateMemberCommandFixture withNickname(String nickname) { + this.nickname = nickname; + return this; + } + + public CreateMemberCommandFixture withCategory(String category) { + this.category = category; + return this; + } + + public CreateMemberCommand build() { + return new CreateMemberCommand(socialId, email, profileImage, nickname, category); + } +} diff --git a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java index 04c2cc5..d5c256b 100644 --- a/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java +++ b/src/test/java/com/ject/studytrip/member/fixture/MemberFixture.java @@ -2,59 +2,37 @@ import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.domain.model.MemberCategory; -import com.ject.studytrip.member.domain.model.MemberRole; -import com.ject.studytrip.member.domain.model.SocialProvider; +import com.ject.studytrip.member.factory.MemberFactory; import org.springframework.test.util.ReflectionTestUtils; public class MemberFixture { - private static final String NICKNAME = "민우"; + private static final String KAKAO_ID = "12345"; + private static final String EMAIL = "choi@kakao.com"; + private static final String PROFILE_IMAGE = "https://kakao.com/profile.jpg"; + private static final String MEMBER_NICKNAME = "민우"; private static final MemberCategory MEMBER_CATEGORY = MemberCategory.STUDENT; public static Member createMemberFromKakao() { - return Member.of( - SocialProvider.KAKAO, - "12345", - "choi@kakao.com", - NICKNAME, - "https://kakao.com/profile.jpg", - MEMBER_CATEGORY, - MemberRole.ROLE_USER); + return MemberFactory.createFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, MEMBER_NICKNAME, MEMBER_CATEGORY); } public static Member createMemberFromKakao(String email, String nickname) { - return Member.of( - SocialProvider.KAKAO, - "12345", - email, - nickname, - "https://kakao.com/profile.jpg", - MEMBER_CATEGORY, - MemberRole.ROLE_USER); + return MemberFactory.createFromKakao( + KAKAO_ID, email, PROFILE_IMAGE, nickname, MEMBER_CATEGORY); } public static Member createMemberFromKakaoWithId(Long id) { Member member = - Member.of( - SocialProvider.KAKAO, - "12345", - "choi@kakao.com", - NICKNAME, - "https://kakao.com/profile.jpg", - MEMBER_CATEGORY, - MemberRole.ROLE_USER); + MemberFactory.createFromKakao( + KAKAO_ID, EMAIL, PROFILE_IMAGE, MEMBER_NICKNAME, MEMBER_CATEGORY); ReflectionTestUtils.setField(member, "id", id); return member; } public static Member createMemberWithoutProfileImageFromKakao() { - return Member.of( - SocialProvider.KAKAO, - "12345", - "choi@kakao.com", - NICKNAME, - null, - MEMBER_CATEGORY, - MemberRole.ROLE_USER); + return MemberFactory.createFromKakao( + KAKAO_ID, EMAIL, null, MEMBER_NICKNAME, MEMBER_CATEGORY); } } diff --git a/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java b/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java new file mode 100644 index 0000000..b26f100 --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/fixture/UpdateMemberRequestFixture.java @@ -0,0 +1,22 @@ +package com.ject.studytrip.member.fixture; + +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; + +public class UpdateMemberRequestFixture { + private String nickname = null; + private String category = null; + + public UpdateMemberRequestFixture withNickname(String nickname) { + this.nickname = nickname; + return this; + } + + public UpdateMemberRequestFixture withCategory(String category) { + this.category = category; + return this; + } + + public UpdateMemberRequest build() { + return new UpdateMemberRequest(nickname, category); + } +} diff --git a/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java new file mode 100644 index 0000000..4681317 --- /dev/null +++ b/src/test/java/com/ject/studytrip/member/presentation/controller/MemberControllerIntegrationTest.java @@ -0,0 +1,300 @@ +package com.ject.studytrip.member.presentation.controller; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.ject.studytrip.BaseIntegrationTest; +import com.ject.studytrip.auth.domain.error.AuthErrorCode; +import com.ject.studytrip.auth.fixture.TokenFixture; +import com.ject.studytrip.auth.helper.TokenTestHelper; +import com.ject.studytrip.member.domain.error.MemberErrorCode; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.fixture.UpdateMemberRequestFixture; +import com.ject.studytrip.member.helper.MemberTestHelper; +import com.ject.studytrip.member.presentation.dto.request.UpdateMemberRequest; +import com.ject.studytrip.trip.domain.model.TripCategory; +import com.ject.studytrip.trip.helper.TripTestHelper; +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.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.ResultActions; + +@DisplayName("MemberController 통합 테스트") +class MemberControllerIntegrationTest extends BaseIntegrationTest { + private static final String BASE_MEMBER_URL = "/api/members"; + + @Autowired private MemberTestHelper memberTestHelper; + @Autowired private TokenTestHelper tokenTestHelper; + @Autowired private TripTestHelper tripTestHelper; + + private Member member; + private String accessToken; + + @BeforeEach + void setUp() { + member = memberTestHelper.saveMember(); + accessToken = + tokenTestHelper.createAccessToken( + member.getId().toString(), member.getRole().name()); + tripTestHelper.saveTrip(member, TripCategory.COURSE); + tripTestHelper.saveTrip(member, TripCategory.COURSE); + tripTestHelper.saveTrip(member, TripCategory.COURSE); + tripTestHelper.saveTrip(member, TripCategory.EXPLORE); + tripTestHelper.saveTrip(member, TripCategory.EXPLORE); + } + + @Nested + @DisplayName("멤버 수정 API") + class UpdateMember { + private final UpdateMemberRequestFixture fixture = new UpdateMemberRequestFixture(); + + private ResultActions getResultActions(String accessToken, UpdateMemberRequest request) + throws Exception { + return mockMvc.perform( + patch(BASE_MEMBER_URL + "/me") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // given + UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); + + // when + ResultActions resultActions = getResultActions("", request); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") + void shouldReturnNotFoundWhenMemberAlreadyDeleted() throws Exception { + // given + UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); + member.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 멤버 닉네임을 수정한다.") + void shouldUpdateMemberNicknameWhenRequestIsValid() throws Exception { + // given + UpdateMemberRequest request = fixture.withNickname("새로운 닉네임").build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 멤버 카테고리를 수정한다.") + void shouldUpdateMemberCategoryWhenRequestIsValid() throws Exception { + // given + UpdateMemberRequest request = fixture.withCategory("WORKER").build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + + @Test + @DisplayName("유효한 요청이 들어오면 멤버 닉네임과 카테고리를 수정한다.") + void shouldUpdateMemberNicknameAndCategoryWhenRequestIsValid() throws Exception { + // given + UpdateMemberRequest request = + fixture.withNickname("새로운 닉네임").withCategory("WORKER").build(); + + // when + ResultActions resultActions = getResultActions(accessToken, request); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } + + @Nested + @DisplayName("멤버 삭제 API") + class DeleteMember { + private ResultActions getResultActions(String accessToken) throws Exception { + return mockMvc.perform( + delete(BASE_MEMBER_URL + "/me") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = getResultActions(""); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") + void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { + // given + member.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 멤버 ID가 들어오면 멤버를 삭제한다.") + void shouldDeleteMemberWhenMemberIdIsValid() throws Exception { + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())); + } + } + + @Nested + @DisplayName("멤버 상세 조회 API") + class LoadMemberDetail { + private ResultActions getResultActions(String accessToken) throws Exception { + return mockMvc.perform( + get(BASE_MEMBER_URL + "/me") + .header( + HttpHeaders.AUTHORIZATION, + TokenFixture.TOKEN_PREFIX + accessToken) + .contentType(MediaType.APPLICATION_JSON)); + } + + @Test + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { + // when + ResultActions resultActions = getResultActions(""); + + // then + resultActions + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(AuthErrorCode.UNAUTHENTICATED.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(AuthErrorCode.UNAUTHENTICATED.getMessage())); + } + + @Test + @DisplayName("삭제된 여행일 경우 404 Not Found를 반환한다.") + void shouldReturnBadRequestWhenMemberAlreadyDeleted() throws Exception { + // given + member.updateDeletedAt(); + + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect( + jsonPath("$.status") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getStatus().value())) + .andExpect( + jsonPath("$.data.message") + .value(MemberErrorCode.MEMBER_NOT_FOUND.getMessage())); + } + + @Test + @DisplayName("유효한 멤버 ID가 들어오면 멤버 상세 정보를 반환한다.") + void shouldReturnMemberDetailWhenMemberIdIsValid() throws Exception { + // when + ResultActions resultActions = getResultActions(accessToken); + + // then + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.success").value(true)) + .andExpect(jsonPath("$.status").value(HttpStatus.OK.value())) + .andExpect(jsonPath("$.data.memberId").value(member.getId())) + .andExpect(jsonPath("$.data.email").value(member.getEmail())) + .andExpect(jsonPath("$.data.nickname").value(member.getNickname())) + .andExpect(jsonPath("$.data.profileImage").value(member.getProfileImage())) + .andExpect(jsonPath("$.data.category").value(member.getCategory().name())) + .andExpect(jsonPath("$.data.courseTripCount").value(3)) + .andExpect(jsonPath("$.data.exploreTripCount").value(2)) + .andExpect(jsonPath("$.data.studyLogCount").value(0)); + } + } +} diff --git a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java index 3f349b5..78e828e 100644 --- a/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java +++ b/src/test/java/com/ject/studytrip/mission/application/service/MissionServiceTest.java @@ -35,6 +35,7 @@ import org.mockito.Mock; import org.springframework.test.util.ReflectionTestUtils; +@DisplayName("MissionService 단위 테스트") class MissionServiceTest extends BaseUnitTest { private static final String NEW_MISSION_NAME = "NEW MISSION NAME"; private static final String NEW_MISSION_MEMO = "NEW MISSION MEMO"; @@ -260,7 +261,7 @@ void shouldThrowExceptionWhenMissionIsDeleted() { @Test @DisplayName("중복된 미션 ID가 존재하면 예외가 발생한다.") - void shouldThrowExceptionWhenDuplicatedIds() { + void shouldThrowExceptionWhenIdsDuplicated() { // given Long stampId = exploreStamp.getId(); List ids = List.of(1L, 1L); @@ -442,7 +443,7 @@ void shouldThrowExceptionWhenMissionIsDeleted() { @Test @DisplayName("특정 스탬프에 속하고 삭제되지 않은 미션이 존재하면, 해당 미션을 반환한다.") - void shouldReturnMissionWhenValid() { + void shouldReturnValidMission() { // given Long missionId = exploreMission1.getId(); Long stampId = exploreStamp.getId(); diff --git a/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java b/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java index afd41b5..bc0a479 100644 --- a/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java +++ b/src/test/java/com/ject/studytrip/mission/presentation/controller/MissionControllerIntegrationTest.java @@ -38,6 +38,7 @@ import org.springframework.http.MediaType; import org.springframework.test.web.servlet.ResultActions; +@DisplayName("MissionController 통합 테스트") class MissionControllerIntegrationTest extends BaseIntegrationTest { private static final String BASE_MISSION_URL = "/api/trips/{tripId}/stamps/{stampId}/missions"; @@ -111,8 +112,8 @@ private ResultActions getResultActions( } @Test - @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { // given CreateMissionRequest request = fixture.build(); @@ -395,7 +396,7 @@ void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { } @Test - @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { // given Long invalidTripId = 10000L; @@ -415,7 +416,7 @@ void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { // given Long invalidStampId = 10000L; @@ -475,8 +476,8 @@ private ResultActions getResultActions( } @Test - @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { // given UpdateMissionRequest request = fixture.withName("새로운 미션 이름").build(); @@ -739,7 +740,7 @@ void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { } @Test - @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { // given Long invalidTripId = 10000L; @@ -764,7 +765,7 @@ void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { // given Long invalidStampId = 10000L; @@ -789,7 +790,7 @@ void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 미션 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 미션 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenMissionIdIsInvalid() throws Exception { // given Long invalidMissionId = 10000L; @@ -903,8 +904,8 @@ private ResultActions getResultActions( } @Test - @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { // given List ids = List.of(courseMission2.getId(), courseMission1.getId()); UpdateMissionOrderRequest request = fixture.withOrderedIds(ids).build(); @@ -1106,7 +1107,7 @@ void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { } @Test - @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { // given Long invalidTripId = 10000L; @@ -1127,7 +1128,7 @@ void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { // given Long invalidStampId = 10000L; @@ -1149,7 +1150,7 @@ void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { @Test @DisplayName("중복된 미션 ID가 들어오면 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenDuplicatedIds() throws Exception { + void shouldReturnBadRequestWhenIdsDuplicated() throws Exception { // given List duplicatedIds = List.of(courseMission1.getId(), courseMission1.getId()); UpdateMissionOrderRequest request = fixture.withOrderedIds(duplicatedIds).build(); @@ -1195,7 +1196,7 @@ void shouldReturnBadRequestWhenSizeMismatch() throws Exception { } @Test - @DisplayName("요청된 미션 ID 목록에 존재하지 않는 ID가 포함되어 있으면 400 Bad Request를 반환한다.") + @DisplayName("요청된 미션 ID 목록에 유효하지 않은 미션 ID가 포함되어 있으면 400 Bad Request를 반환한다.") void shouldReturnBadRequestWhenIdsContainInvalidMissionId() throws Exception { // given Long invalidMissionId = 10000L; @@ -1251,8 +1252,8 @@ private ResultActions getResultActions( } @Test - @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { // when ResultActions resultActions = getResultActions( @@ -1478,7 +1479,7 @@ void shouldReturnForbiddenWhenMissionNotBelongToStamp() throws Exception { } @Test - @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { // given Long invalidTripId = 10000L; @@ -1500,7 +1501,7 @@ void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { // given Long invalidStampId = 10000L; @@ -1523,7 +1524,7 @@ void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 미션 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 미션 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenMissionIdIsInvalid() throws Exception { // given Long invalidMissionId = 10000L; @@ -1577,8 +1578,8 @@ private ResultActions getResultActions(String accessToken, Object tripId, Object } @Test - @DisplayName("인증되지 않은 사용자일 경우 400 Bad Request를 반환한다.") - void shouldReturnBadRequestWhenUnauthenticated() throws Exception { + @DisplayName("Access Token이 없으면 401 Unauthorized를 반환한다.") + void shouldReturnUnauthorizedWhenAccessTokenIsMissing() throws Exception { // when ResultActions resultActions = getResultActions("", exploreTrip.getId(), exploreStamp.getId()); @@ -1707,7 +1708,7 @@ void shouldReturnForbiddenWhenStampNotBelongToTrip() throws Exception { } @Test - @DisplayName("존재하지 않는 여행 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 여행 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { // given Long invalidTripId = 10000L; @@ -1726,7 +1727,7 @@ void shouldReturnNotFoundWhenTripIdIsInvalid() throws Exception { } @Test - @DisplayName("존재하지 않는 스탬프 ID가 들어오면 404 Not Found를 반환한다.") + @DisplayName("유효하지 않은 스탬프 ID가 들어오면 404 Not Found를 반환한다.") void shouldReturnNotFoundWhenStampIdIsInvalid() throws Exception { // given Long invalidStampId = 10000L; diff --git a/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java new file mode 100644 index 0000000..881e648 --- /dev/null +++ b/src/test/java/com/ject/studytrip/studylog/application/service/StudyLogServiceTest.java @@ -0,0 +1,61 @@ +package com.ject.studytrip.studylog.application.service; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.BDDMockito.given; + +import com.ject.studytrip.BaseUnitTest; +import com.ject.studytrip.member.domain.model.Member; +import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.studylog.domain.repository.StudyLogQueryRepository; +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.mockito.InjectMocks; +import org.mockito.Mock; + +class StudyLogServiceTest extends BaseUnitTest { + + @InjectMocks private StudyLogService studyLogService; + @Mock private StudyLogQueryRepository studyLogQueryRepository; + + private Member member; + + @BeforeEach + void setUp() { + member = MemberFixture.createMemberFromKakaoWithId(1L); + } + + @Nested + @DisplayName("getActiveStudyLogCountByMemberId 메서드는") + class GetActiveStudyLogCountByMemberId { + + @Test + @DisplayName("해당 멤버의 학습 기록이 존재하지 않으면 0을 반환한다.") + void shouldReturnZeroWhenStudyLogDoesNotExistForMember() { + // given + given(studyLogQueryRepository.countActiveStudyLogsByMemberId(member.getId())) + .willReturn(0L); + + // when + long result = studyLogService.getActiveStudyLogCountByMemberId(member.getId()); + + // then + assertThat(result).isZero(); + } + + @Test + @DisplayName("해당 멤버의 학습 기록이 존재하면 그 개수를 반환한다.") + void shouldReturnCountWhenStudyLogExistsForMember() { + // given + given(studyLogQueryRepository.countActiveStudyLogsByMemberId(member.getId())) + .willReturn(3L); + + // when + long result = studyLogService.getActiveStudyLogCountByMemberId(member.getId()); + + // then + assertThat(result).isEqualTo(3L); + } + } +} diff --git a/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java index 447dbd9..81e3b8e 100644 --- a/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java +++ b/src/test/java/com/ject/studytrip/trip/application/service/TripServiceTest.java @@ -9,6 +9,7 @@ import com.ject.studytrip.global.exception.CustomException; import com.ject.studytrip.member.domain.model.Member; import com.ject.studytrip.member.fixture.MemberFixture; +import com.ject.studytrip.trip.application.dto.TripCount; import com.ject.studytrip.trip.domain.error.TripErrorCode; import com.ject.studytrip.trip.domain.model.Trip; import com.ject.studytrip.trip.domain.model.TripCategory; @@ -298,4 +299,51 @@ void shouldGetTripsReturnSlicePaged() { assertThat(sliceTrips.hasNext()).isFalse(); } } + + @Nested + @DisplayName("getActiveTripCountsByMemberId 메서드는") + class GetActiveTripCountsByMemberId { + + @Test + @DisplayName("해당 멤버의 여행이 존재하지 않으면 0을 반환한다.") + void shouldReturnZeroWhenTripDoesNotExistForMember() { + // given + given( + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + member.getId(), TripCategory.COURSE)) + .willReturn(0L); + given( + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + member.getId(), TripCategory.EXPLORE)) + .willReturn(0L); + + // when + TripCount result = tripService.getActiveTripCountsByMemberId(member.getId()); + + // then + assertThat(result.course()).isZero(); + assertThat(result.explore()).isZero(); + } + + @Test + @DisplayName("코스형과 탐험형 여행 개수를 각각 조회하여 TripCount를 반환한다.") + void shouldReturnTripCountByCategory() { + // given + given( + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + member.getId(), TripCategory.COURSE)) + .willReturn(3L); + given( + tripQueryRepository.countActiveTripsByMemberIdAndCategory( + member.getId(), TripCategory.EXPLORE)) + .willReturn(2L); + + // when + TripCount result = tripService.getActiveTripCountsByMemberId(member.getId()); + + // then + assertThat(result.course()).isEqualTo(3L); + assertThat(result.explore()).isEqualTo(2L); + } + } }