diff --git a/.github/workflows/pr_test.yml b/.github/workflows/pr_test.yml new file mode 100644 index 00000000..9adcb813 --- /dev/null +++ b/.github/workflows/pr_test.yml @@ -0,0 +1,25 @@ +name: PR Test + +on: + pull_request: + branches: [ develop ] # develop branch에 PR을 보낼 때 실행 + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + + # Gradle wrapper 파일 실행 권한주기 + - name: Grant execute permission for gradlew + run: chmod +x gradlew + + # Gradle test를 실행 + - name: Test with Gradle + run: ./gradlew --info test diff --git a/.gitignore b/.gitignore index 356ee5bd..a5998317 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,9 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ +/src/main/generated/** + + # General .DS_Store .AppleDouble diff --git a/build.gradle b/build.gradle index bd5d4344..871af92e 100644 --- a/build.gradle +++ b/build.gradle @@ -51,15 +51,34 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' - // security - // implementation 'org.springframework.boot:spring-boot-starter-security' - // testImplementation 'org.springframework.security:spring-security-test' + // Querydsl + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" developmentOnly 'org.springframework.boot:spring-boot-devtools' -// developmentOnly 'org.springframework.boot:spring-boot-docker-compose' } +// Querydsl 설정부 +def generated = 'src/main/generated' + +// querydsl QClass 파일 생성 위치를 지정 +tasks.withType(JavaCompile) { + options.getGeneratedSourceOutputDirectory().set(file(generated)) +} + +// java source set 에 querydsl QClass 위치 추가 +sourceSets { + main.java.srcDirs += [generated] +} + +// gradle clean 시에 QClass 디렉토리 삭제 +clean { + delete file(generated) +} + tasks.named('test') { useJUnitPlatform() } diff --git a/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockFacadeService.java b/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockFacadeService.java index 0208db5a..eb2e8cc8 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockFacadeService.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockFacadeService.java @@ -63,6 +63,7 @@ public void unBlockMember(Member member, Long targetMemberId) { * @param member * @param targetMemberId */ + @Transactional public void deleteBlock(Member member, Long targetMemberId) { Member targetMember = memberService.findMember(targetMemberId); blockService.deleteBlock(member, targetMember); diff --git a/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockService.java b/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockService.java index b3796f76..1ed5d4ca 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockService.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/block/service/BlockService.java @@ -66,6 +66,7 @@ public Page findBlockedMembersByBlockerId(Long blockerId, Pageable pagea * @param member * @param targetMember */ + @Transactional public void unBlockMember(Member member, Member targetMember) { // 대상 회원의 탈퇴 여부 검증 memberValidator.validateTargetMemberIsNotBlind(targetMember); @@ -85,6 +86,7 @@ public void unBlockMember(Member member, Member targetMember) { * @param member * @param targetMember */ + @Transactional public void deleteBlock(Member member, Member targetMember) { // targetMember가 차단 목록에 존재하는지 검증 및 block 엔티티 조회 Block block = blockRepository.findByBlockerMemberAndBlockedMember(member, targetMember) diff --git a/src/main/java/com/gamegoo/gamegoo_v2/common/annotation/ValidCursor.java b/src/main/java/com/gamegoo/gamegoo_v2/common/annotation/ValidCursor.java new file mode 100644 index 00000000..0cdb03e6 --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/common/annotation/ValidCursor.java @@ -0,0 +1,23 @@ +package com.gamegoo.gamegoo_v2.common.annotation; + +import com.gamegoo.gamegoo_v2.common.annotationValidator.ValidCursorAnnotationValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Constraint(validatedBy = ValidCursorAnnotationValidator.class) +@Target(ElementType.PARAMETER) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidCursor { + + String message() default "커서는 1 이상의 값이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/common/annotationValidator/ValidCursorAnnotationValidator.java b/src/main/java/com/gamegoo/gamegoo_v2/common/annotationValidator/ValidCursorAnnotationValidator.java new file mode 100644 index 00000000..eba9d0d8 --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/common/annotationValidator/ValidCursorAnnotationValidator.java @@ -0,0 +1,23 @@ +package com.gamegoo.gamegoo_v2.common.annotationValidator; + +import com.gamegoo.gamegoo_v2.common.annotation.ValidCursor; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ValidCursorAnnotationValidator implements ConstraintValidator { + + @Override + public void initialize(ValidCursor constraintAnnotation) { + ConstraintValidator.super.initialize(constraintAnnotation); + } + + @Override + public boolean isValid(Long value, ConstraintValidatorContext context) { + if (value != null && value < 1) { + return false; + } + + return true; + } + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/config/QuerydslConfig.java b/src/main/java/com/gamegoo/gamegoo_v2/config/QuerydslConfig.java new file mode 100644 index 00000000..bdcc8c9c --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/config/QuerydslConfig.java @@ -0,0 +1,20 @@ +package com.gamegoo.gamegoo_v2.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @Autowired + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/exception/common/ExceptionAdvice.java b/src/main/java/com/gamegoo/gamegoo_v2/exception/common/ExceptionAdvice.java index d905fb4d..f6d0b980 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/exception/common/ExceptionAdvice.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/exception/common/ExceptionAdvice.java @@ -6,12 +6,15 @@ import jakarta.validation.ConstraintViolationException; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; +import java.util.List; import java.util.stream.Collectors; import static org.springframework.http.HttpStatus.BAD_REQUEST; @@ -58,7 +61,7 @@ public ResponseEntity> handleHttpMessageNotReadableException(Http .body(ApiResponse.of(BAD_REQUEST, "확인할 수 없는 형태의 데이터가 들어왔습니다")); } - // @Validated 검증 실패 시 발생하는 에러 + // 메소드 파라미터 검증 실패 시 발생하는 에러 @ExceptionHandler(ConstraintViolationException.class) public ResponseEntity> handlerConstraintViolationException(ConstraintViolationException e) { // ConstraintViolationException에서 메시지 추출 @@ -69,6 +72,26 @@ public ResponseEntity> handlerConstraintViolationException(Constr return ResponseEntity.badRequest().body(ApiResponse.of(BAD_REQUEST, errorMessage)); } + // 컨트롤러 @Validated 검증 실패 시 발생하는 에러 + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity> handlerHandlerMethodValidationException(HandlerMethodValidationException e) { + // 모든 제약 위반 메시지를 추출 + List errorMessages = e.getAllErrors().stream() + .map(error -> { + if (error instanceof FieldError) { + return error.getDefaultMessage(); + } else { + return error.getDefaultMessage(); + } + }) + .collect(Collectors.toList()); + + // 첫 번째 오류 메시지만 반환 + String responseMessage = String.join(", ", errorMessages); + + return ResponseEntity.badRequest().body(ApiResponse.of(BAD_REQUEST, responseMessage)); + } + // 필수 query parameter 누락 시 발생하는 에러 @ExceptionHandler(MissingServletRequestParameterException.class) public ResponseEntity> handleMissingServletRequestParameterException( diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/controller/FriendController.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/controller/FriendController.java index aef14844..051af312 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/friend/controller/FriendController.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/controller/FriendController.java @@ -2,7 +2,9 @@ import com.gamegoo.gamegoo_v2.auth.annotation.AuthMember; import com.gamegoo.gamegoo_v2.common.ApiResponse; +import com.gamegoo.gamegoo_v2.common.annotation.ValidCursor; import com.gamegoo.gamegoo_v2.friend.dto.DeleteFriendResponse; +import com.gamegoo.gamegoo_v2.friend.dto.FriendListResponse; import com.gamegoo.gamegoo_v2.friend.dto.FriendRequestResponse; import com.gamegoo.gamegoo_v2.friend.dto.StarFriendResponse; import com.gamegoo.gamegoo_v2.friend.service.FriendFacadeService; @@ -11,17 +13,23 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + @Tag(name = "Friend", description = "친구 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/v2/friends") +@Validated public class FriendController { private final FriendFacadeService friendFacadeService; @@ -49,7 +57,7 @@ public ApiResponse rejectFriendRequest(@PathVariable(name @AuthMember Member member) { return ApiResponse.ok(friendFacadeService.rejectFriendRequest(member, targetMemberId)); } - + @Operation(summary = "친구 요청 취소 API", description = "대상 회원에게 보낸 친구 요청을 취소하는 API 입니다.") @Parameter(name = "memberId", description = "친구 요청을 취소할 대상 회원의 id 입니다.") @DeleteMapping("/request/{memberId}") @@ -74,4 +82,20 @@ public ApiResponse deleteFriend(@PathVariable(name = "memb return ApiResponse.ok(friendFacadeService.deleteFriend(member, targetMemberId)); } + @Operation(summary = "모든 친구 id 조회 API", description = "해당 회원의 모든 친구 id 목록을 조회하는 API 입니다. " + + "정렬 기능 없음, socket서버용 API입니다.") + @GetMapping("/ids") + public ApiResponse> getFriendIds(@AuthMember Member member) { + return ApiResponse.ok(friendFacadeService.getFriendIdList(member)); + } + + @Operation(summary = "친구 목록 조회 API", description = "해당 회원의 친구 목록을 조회하는 API 입니다. 이름 오름차순(한글-영문-숫자 순)으로 정렬해 제공합니다." + + "cursor를 보내지 않으면 상위 10개 친구 목록을 조회합니다.") + @Parameter(name = "cursor", description = "페이징을 위한 커서, 이전 친구 목록 조회에서 응답받은 next_cursor를 보내주세요.") + @GetMapping + public ApiResponse getFriendList( + @ValidCursor @RequestParam(name = "cursor", required = false) Long cursor, @AuthMember Member member) { + return ApiResponse.ok(friendFacadeService.getFriends(member, cursor)); + } + } diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/dto/FriendListResponse.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/dto/FriendListResponse.java new file mode 100644 index 00000000..fceed1aa --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/dto/FriendListResponse.java @@ -0,0 +1,59 @@ +package com.gamegoo.gamegoo_v2.friend.dto; + +import com.gamegoo.gamegoo_v2.friend.domain.Friend; +import lombok.Builder; +import lombok.Getter; +import org.springframework.data.domain.Slice; + +import java.util.List; + +@Getter +@Builder +public class FriendListResponse { + + List friendInfoDTOList; + int listSize; + boolean hasNext; + Long nextCursor; + + @Getter + @Builder + public static class FriendInfoResponse { + + Long memberId; + String name; + int profileImg; + boolean isLiked; + boolean isBlind; + + public static FriendInfoResponse of(Friend friend) { + String name = friend.getToMember().isBlind() ? "(탈퇴한 사용자)" : friend.getToMember().getGameName(); + return FriendInfoResponse.builder() + .memberId(friend.getToMember().getId()) + .profileImg(friend.getToMember().getProfileImage()) + .name(name) + .isLiked(friend.isLiked()) + .isBlind(friend.getToMember().isBlind()) + .build(); + } + + } + + public static FriendListResponse of(Slice friends) { + List friendInfoResponseList = friends.stream() + .map(FriendInfoResponse::of) + .toList(); + + Long nextCursor = friends.hasNext() + ? friends.getContent().get(friendInfoResponseList.size() - 1).getToMember().getId() + : null; + + return FriendListResponse.builder() + .friendInfoDTOList(friendInfoResponseList) + .listSize(friendInfoResponseList.size()) + .hasNext(friends.hasNext()) + .nextCursor(nextCursor) + .build(); + } + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepository.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepository.java index dfe3e80a..53176034 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepository.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepository.java @@ -4,7 +4,7 @@ import com.gamegoo.gamegoo_v2.member.domain.Member; import org.springframework.data.jpa.repository.JpaRepository; -public interface FriendRepository extends JpaRepository { +public interface FriendRepository extends JpaRepository, FriendRepositoryCustom { boolean existsByFromMemberAndToMember(Member fromMember, Member toMember); diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustom.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustom.java new file mode 100644 index 00000000..f0b856a2 --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustom.java @@ -0,0 +1,10 @@ +package com.gamegoo.gamegoo_v2.friend.repository; + +import com.gamegoo.gamegoo_v2.friend.domain.Friend; +import org.springframework.data.domain.Slice; + +public interface FriendRepositoryCustom { + + Slice findFriendsByCursorAndOrdered(Long memberId, Long cursor, Integer pageSize); + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustomImpl.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustomImpl.java new file mode 100644 index 00000000..21a86dfe --- /dev/null +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/repository/FriendRepositoryCustomImpl.java @@ -0,0 +1,128 @@ +package com.gamegoo.gamegoo_v2.friend.repository; + +import com.gamegoo.gamegoo_v2.friend.domain.Friend; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; + +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import static com.gamegoo.gamegoo_v2.friend.domain.QFriend.friend; + +@RequiredArgsConstructor +public class FriendRepositoryCustomImpl implements FriendRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice findFriendsByCursorAndOrdered(Long memberId, Long cursorId, Integer pageSize) { + // 전체 친구 목록 조회 + List allFriends = queryFactory.selectFrom(friend) + .where(friend.fromMember.id.eq(memberId)) + .fetch(); + + // 친구 목록 전체 정렬 + allFriends.sort( + (f1, f2) -> memberNameComparator.compare(f1.getToMember().getGameName(), + f2.getToMember().getGameName())); + + // 정렬된 데이터에서 페이징 적용 + // cursorId에 해당하는 요소의 인덱스 찾기 + int startIndex = findCursorIndex(allFriends, cursorId); + + List pagedFriends = allFriends.stream() + .skip(startIndex) + .limit(pageSize + 1) // 다음 페이지가 있는지 확인하기 위해 +1 + .collect(Collectors.toList()); + + boolean hasNext = pagedFriends.size() > pageSize; + if (hasNext) { + pagedFriends.remove(pagedFriends.size() - 1); // 다음 페이지가 있으면 마지막 요소를 제거 + } + + return new SliceImpl<>(pagedFriends, Pageable.unpaged(), hasNext); + } + + + /** + * cursorId에 해당하는 Friend 객체의 다음 인덱스 찾기 + * + * @param allFriends + * @param cursorId + * @return + */ + private static int findCursorIndex(List allFriends, Long cursorId) { + if (cursorId == null) { + return 0; + } + + for (int i = 0; i < allFriends.size(); i++) { + if (allFriends.get(i).getToMember().getId().equals(cursorId)) { + return i + 1; + } + } + return 0; // cursorId에 해당하는 객체를 찾지 못하면 0을 리턴 + } + + private static final Comparator memberNameComparator = (s1, s2) -> { + int length1 = s1.length(); + int length2 = s2.length(); + int minLength = Math.min(length1, length2); + + // 각 문자 비교 + for (int i = 0; i < minLength; i++) { + int result = compareChars(s1.charAt(i), s2.charAt(i)); + if (result != 0) { + return result; + } + } + + // 앞부분이 동일하면, 길이가 짧은 것이 앞으로 오도록 정렬 + return Integer.compare(length1, length2); + }; + + /** + * 문자 비교 메서드: 한글 -> 영문자 -> 숫자 순으로 우선순위 지정 + * + * @param c1 + * @param c2 + * @return + */ + private static int compareChars(char c1, char c2) { + boolean isC1Korean = isKorean(c1); + boolean isC2Korean = isKorean(c2); + + // 한글과 영문자/숫자를 구분하여 우선순위 설정 + if (isC1Korean && !isC2Korean) { + return -1; // 한글은 영문자/숫자보다 먼저 + } else if (!isC1Korean && isC2Korean) { + return 1; // 영문자/숫자는 한글보다 뒤 + } else if (Character.isDigit(c1) && Character.isDigit(c2)) { + return Character.compare(c1, c2); // 둘 다 숫자인 경우 숫자 비교 + } else if (Character.isDigit(c1)) { + return 1; // 숫자는 항상 뒤로 + } else if (Character.isDigit(c2)) { + return -1; // 숫자는 항상 뒤로 + } else { + return Character.compare(c1, c2); // 기본적으로 문자 비교 (영문자끼리 등) + } + } + + /** + * 한글 여부를 판별 + * + * @param c + * @return + */ + private static boolean isKorean(char c) { + return (c >= 0x1100 && c <= 0x11FF) || // 한글 자모 + (c >= 0xAC00 && c <= 0xD7AF) || // 한글 음절 + (c >= 0x3130 && c <= 0x318F); // 한글 호환 자모 + } + + +} diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendFacadeService.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendFacadeService.java index a753409b..b1c2763f 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendFacadeService.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendFacadeService.java @@ -3,6 +3,7 @@ import com.gamegoo.gamegoo_v2.friend.domain.Friend; import com.gamegoo.gamegoo_v2.friend.domain.FriendRequest; import com.gamegoo.gamegoo_v2.friend.dto.DeleteFriendResponse; +import com.gamegoo.gamegoo_v2.friend.dto.FriendListResponse; import com.gamegoo.gamegoo_v2.friend.dto.FriendRequestResponse; import com.gamegoo.gamegoo_v2.friend.dto.StarFriendResponse; import com.gamegoo.gamegoo_v2.member.domain.Member; @@ -11,6 +12,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -63,8 +66,8 @@ public FriendRequestResponse rejectFriendRequest(Member member, Long targetMembe return FriendRequestResponse.of(friendRequest.getFromMember().getId(), "친구 요청 거절 성공"); } - - /** + + /** * 친구 요청 취소 Facade 메소드 * * @param member @@ -109,4 +112,25 @@ public DeleteFriendResponse deleteFriend(Member member, Long targetMemberId) { return DeleteFriendResponse.of(targetMemberId); } + /** + * 모든 친구 id 목록 조회 Facade 메소드 + * + * @param member + * @return + */ + public List getFriendIdList(Member member) { + return member.getFriendList().stream().map(friend -> friend.getToMember().getId()).toList(); + } + + /** + * 친구 목록 조회 Facade 메소드 + * + * @param member + * @param cursor + * @return + */ + public FriendListResponse getFriends(Member member, Long cursor) { + return FriendListResponse.of(friendService.getFriends(member, cursor)); + } + } diff --git a/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendService.java b/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendService.java index 6d96b427..69c4cefc 100644 --- a/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendService.java +++ b/src/main/java/com/gamegoo/gamegoo_v2/friend/service/FriendService.java @@ -12,6 +12,7 @@ import com.gamegoo.gamegoo_v2.friend.repository.FriendRequestRepository; import com.gamegoo.gamegoo_v2.member.domain.Member; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,6 +27,8 @@ public class FriendService { private final MemberValidator memberValidator; private final FriendValidator friendValidator; + private final static int PAGE_SIZE = 10; + /** * 친구 요청 생성 메소드 * @@ -188,6 +191,16 @@ public void deleteFriend(Member member, Member targetMember) { } } + /** + * 해당 회원의 친구 목록 Slice 객체 반환하는 메소드 + * + * @param member + * @return + */ + public Slice getFriends(Member member, Long cursor) { + return friendRepository.findFriendsByCursorAndOrdered(member.getId(), cursor, PAGE_SIZE); + } + private void validateNotSelf(Member member, Member targetMember) { if (member.getId().equals(targetMember.getId())) { throw new FriendException(ErrorCode.FRIEND_BAD_REQUEST); diff --git a/src/test/java/com/gamegoo/gamegoo_v2/controller/friend/FriendControllerTest.java b/src/test/java/com/gamegoo/gamegoo_v2/controller/friend/FriendControllerTest.java index fbda7fec..2880c6ef 100644 --- a/src/test/java/com/gamegoo/gamegoo_v2/controller/friend/FriendControllerTest.java +++ b/src/test/java/com/gamegoo/gamegoo_v2/controller/friend/FriendControllerTest.java @@ -5,7 +5,9 @@ import com.gamegoo.gamegoo_v2.exception.MemberException; import com.gamegoo.gamegoo_v2.exception.common.ErrorCode; import com.gamegoo.gamegoo_v2.friend.controller.FriendController; +import com.gamegoo.gamegoo_v2.friend.domain.Friend; import com.gamegoo.gamegoo_v2.friend.dto.DeleteFriendResponse; +import com.gamegoo.gamegoo_v2.friend.dto.FriendListResponse; import com.gamegoo.gamegoo_v2.friend.dto.FriendRequestResponse; import com.gamegoo.gamegoo_v2.friend.dto.StarFriendResponse; import com.gamegoo.gamegoo_v2.friend.service.FriendFacadeService; @@ -13,13 +15,20 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import java.util.ArrayList; +import java.util.List; + import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; @@ -300,6 +309,7 @@ void cancelFriendRequestFailedWhenNoPendingRequest() throws Exception { .andExpect(status().isNotFound()) .andExpect(jsonPath("$.message").value(ErrorCode.PENDING_FRIEND_REQUEST_NOT_EXIST.getMessage())); } + } @Nested @@ -437,4 +447,87 @@ void deleteFriendFailedWhenNotFriend() throws Exception { } + @Nested + @DisplayName("모든 친구 id 조회") + class GetFriendIdListTest { + + @DisplayName("모든 친구 id 조회 성공") + @Test + void getFriendIdListSucceeds() throws Exception { + // given + List response = new ArrayList<>(); + + given(friendFacadeService.getFriendIdList(any())).willReturn(response); + + // when // then + mockMvc.perform(get(API_URL_PREFIX + "/ids")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data").isArray()); + } + + } + + @Nested + @DisplayName("친구 목록 조회") + class GetFriendListTest { + + @DisplayName("친구 목록 조회 성공: cursor가 없는 경우") + @Test + void getFriendListSucceedsWhenNoCursor() throws Exception { + // given + List friends = new ArrayList<>(); + Slice friendSlice = new SliceImpl<>(friends, Pageable.unpaged(), false); + FriendListResponse response = FriendListResponse.of(friendSlice); + + given(friendFacadeService.getFriends(any(), any())).willReturn(response); + + // when // then + mockMvc.perform(get(API_URL_PREFIX)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.friendInfoDTOList").isArray()) + .andExpect(jsonPath("$.data.listSize").isNumber()) + .andExpect(jsonPath("$.data.hasNext").isBoolean()); + } + + @DisplayName("친구 목록 조회 성공: cursor가 있는 경우") + @Test + void getFriendListSucceedsWithCursor() throws Exception { + // given + List friends = new ArrayList<>(); + Slice friendSlice = new SliceImpl<>(friends, Pageable.unpaged(), false); + FriendListResponse response = FriendListResponse.of(friendSlice); + + given(friendFacadeService.getFriends(any(), any())).willReturn(response); + + // when // then + mockMvc.perform(get(API_URL_PREFIX) + .param("cursor", "1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("OK")) + .andExpect(jsonPath("$.data.friendInfoDTOList").isArray()) + .andExpect(jsonPath("$.data.listSize").isNumber()) + .andExpect(jsonPath("$.data.hasNext").isBoolean()); + } + + @DisplayName("친구 목록 조회 실패: cursor가 음수인 경우") + @Test + void getFriendListSucceedsWhenNegativeCursor() throws Exception { + // given + List friends = new ArrayList<>(); + Slice friendSlice = new SliceImpl<>(friends, Pageable.unpaged(), false); + FriendListResponse response = FriendListResponse.of(friendSlice); + + given(friendFacadeService.getFriends(any(), any())).willReturn(response); + + // when // then + mockMvc.perform(get(API_URL_PREFIX) + .param("cursor", "-1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.message").value("커서는 1 이상의 값이어야 합니다.")); + } + + } + } diff --git a/src/test/java/com/gamegoo/gamegoo_v2/integration/block/BlockServiceTest.java b/src/test/java/com/gamegoo/gamegoo_v2/integration/block/BlockServiceTest.java index fbf3abc9..d133697d 100644 --- a/src/test/java/com/gamegoo/gamegoo_v2/integration/block/BlockServiceTest.java +++ b/src/test/java/com/gamegoo/gamegoo_v2/integration/block/BlockServiceTest.java @@ -20,7 +20,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.springframework.transaction.annotation.Transactional; import java.util.Optional; @@ -229,7 +228,6 @@ class DeleteBlockTest { @DisplayName("차단 목록에서 삭제 성공") @Test - @Transactional void deleteBlockSucceeds() { // given Member targetMember = createMember(TARGET_EMAIL, TARGET_GAMENAME); diff --git a/src/test/java/com/gamegoo/gamegoo_v2/integration/friend/FriendQueryServiceTest.java b/src/test/java/com/gamegoo/gamegoo_v2/integration/friend/FriendQueryServiceTest.java new file mode 100644 index 00000000..7310b611 --- /dev/null +++ b/src/test/java/com/gamegoo/gamegoo_v2/integration/friend/FriendQueryServiceTest.java @@ -0,0 +1,197 @@ +package com.gamegoo.gamegoo_v2.integration.friend; + +import com.gamegoo.gamegoo_v2.block.repository.BlockRepository; +import com.gamegoo.gamegoo_v2.friend.domain.Friend; +import com.gamegoo.gamegoo_v2.friend.dto.FriendListResponse; +import com.gamegoo.gamegoo_v2.friend.repository.FriendRepository; +import com.gamegoo.gamegoo_v2.friend.service.FriendFacadeService; +import com.gamegoo.gamegoo_v2.member.domain.LoginType; +import com.gamegoo.gamegoo_v2.member.domain.Member; +import com.gamegoo.gamegoo_v2.member.domain.Tier; +import com.gamegoo.gamegoo_v2.member.repository.MemberRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +@ActiveProfiles("test") +@SpringBootTest +public class FriendQueryServiceTest { + + @Autowired + FriendFacadeService friendFacadeService; + + @Autowired + MemberRepository memberRepository; + + @Autowired + BlockRepository blockRepository; + + @Autowired + FriendRepository friendRepository; + + private static final String MEMBER_EMAIL = "test@gmail.com"; + private static final String MEMBER_GAMENAME = "member"; + private static final String TARGET_EMAIL = "target@naver.com"; + private static final String TARGET_GAMENAME = "target"; + + private Member member; + + @BeforeEach + void setUp() { + member = createMember(MEMBER_EMAIL, MEMBER_GAMENAME); + } + + @AfterEach + void tearDown() { + friendRepository.deleteAllInBatch(); + blockRepository.deleteAllInBatch(); + memberRepository.deleteAllInBatch(); + } + + @Nested + @DisplayName("모든 친구 id 조회") + class GetFriendIdList { + + @DisplayName("모든 친구 id 조회 성공: 친구가 있는 경우") + @Test + void getFriendIdListSucceeds() { + // given + for (int i = 1; i <= 5; i++) { + Member targetMember = createMember("member" + i + "@gmail.com", "member" + i); + + // 친구 관계 생성 + friendRepository.save(Friend.create(member, targetMember)); + friendRepository.save(Friend.create(targetMember, member)); + } + + // when + List friendIdList = friendFacadeService.getFriendIdList(member); + + // then + assertThat(friendIdList).hasSize(5); + } + + @DisplayName("모든 친구 id 조회 성공: 친구가 없는 경우") + @Test + void getFriendIdListSucceedsWhenNoFriend() { + // when + List friendIdList = friendFacadeService.getFriendIdList(member); + + // then + assertThat(friendIdList).hasSize(0); + } + + } + + @Nested + @DisplayName("친구 목록 조회") + class GetFriendList { + + @DisplayName("친구 목록 조회 성공: 친구가 없는 경우") + @Test + void getFriendListSucceedsWhenNoFriend() { + // when + FriendListResponse friends = friendFacadeService.getFriends(member, null); + + // then + assertThat(friends.getListSize()).isEqualTo(0); + assertThat(friends.getNextCursor()).isNull(); + assertThat(friends.isHasNext()).isEqualTo(false); + } + + @DisplayName("친구 목록 조회 성공: 친구가 page size 이하인 경우") + @Test + void getFriendListSucceedsWhenOnePage() { + // given + for (int i = 1; i <= 5; i++) { + Member targetMember = createMember("member" + i + "@gmail.com", "member" + i); + + // 친구 관계 생성 + friendRepository.save(Friend.create(member, targetMember)); + friendRepository.save(Friend.create(targetMember, member)); + } + + // when + FriendListResponse friends = friendFacadeService.getFriends(member, null); + + // then + assertThat(friends.getListSize()).isEqualTo(5); + assertThat(friends.getNextCursor()).isNull(); + assertThat(friends.isHasNext()).isEqualTo(false); + } + + @DisplayName("친구 목록 조회 성공: 친구가 page size 이상이고 cursor를 입력한 경우") + @Test + void getFriendListSucceedsWhenNextPage() { + // given + Long cursorId = 0L; + for (int i = 1; i <= 15; i++) { + Member targetMember = createMember("member" + i + "@gmail.com", "member" + i); + + // 친구 관계 생성 + friendRepository.save(Friend.create(member, targetMember)); + friendRepository.save(Friend.create(targetMember, member)); + + if (i == 4) { + cursorId = targetMember.getId(); + } + } + + // when + FriendListResponse friends = friendFacadeService.getFriends(member, cursorId); + + // then + assertThat(friends.getListSize()).isEqualTo(5); + assertThat(friends.getNextCursor()).isNull(); + assertThat(friends.isHasNext()).isEqualTo(false); + } + + @DisplayName("친구 목록 조회 성공: 친구가 page size 이상이고 cursor를 입력하지 않은 경우") + @Test + void getFriendListSucceedsFirstPage() { + // given + for (int i = 1; i <= 15; i++) { + Member targetMember = createMember("member" + i + "@gmail.com", "member" + i); + + // 친구 관계 생성 + friendRepository.save(Friend.create(member, targetMember)); + friendRepository.save(Friend.create(targetMember, member)); + } + + // when + FriendListResponse friends = friendFacadeService.getFriends(member, null); + + // then + assertThat(friends.getListSize()).isEqualTo(10); + assertThat(friends.getNextCursor()).isNotNull(); + assertThat(friends.isHasNext()).isEqualTo(true); + } + + } + + private Member createMember(String email, String gameName) { + return memberRepository.save(Member.builder() + .email(email) + .password("testPassword") + .profileImage(1) + .loginType(LoginType.GENERAL) + .gameName(gameName) + .tag("TAG") + .tier(Tier.IRON) + .gameRank(0) + .winRate(0.0) + .gameCount(0) + .isAgree(true) + .build()); + } + +} diff --git a/src/test/java/com/gamegoo/gamegoo_v2/repository/block/BlockRepositoryTest.java b/src/test/java/com/gamegoo/gamegoo_v2/repository/block/BlockRepositoryTest.java index 0010fa1d..da09f3db 100644 --- a/src/test/java/com/gamegoo/gamegoo_v2/repository/block/BlockRepositoryTest.java +++ b/src/test/java/com/gamegoo/gamegoo_v2/repository/block/BlockRepositoryTest.java @@ -2,6 +2,7 @@ import com.gamegoo.gamegoo_v2.block.domain.Block; import com.gamegoo.gamegoo_v2.block.repository.BlockRepository; +import com.gamegoo.gamegoo_v2.config.QuerydslConfig; import com.gamegoo.gamegoo_v2.member.domain.LoginType; import com.gamegoo.gamegoo_v2.member.domain.Member; import com.gamegoo.gamegoo_v2.member.domain.Tier; @@ -12,6 +13,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -20,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; @DataJpaTest +@Import(QuerydslConfig.class) class BlockRepositoryTest { @Autowired diff --git a/src/test/java/com/gamegoo/gamegoo_v2/repository/friend/FriendRepositoryTest.java b/src/test/java/com/gamegoo/gamegoo_v2/repository/friend/FriendRepositoryTest.java new file mode 100644 index 00000000..ce560b80 --- /dev/null +++ b/src/test/java/com/gamegoo/gamegoo_v2/repository/friend/FriendRepositoryTest.java @@ -0,0 +1,193 @@ +package com.gamegoo.gamegoo_v2.repository.friend; + +import com.gamegoo.gamegoo_v2.config.QuerydslConfig; +import com.gamegoo.gamegoo_v2.friend.domain.Friend; +import com.gamegoo.gamegoo_v2.friend.repository.FriendRepository; +import com.gamegoo.gamegoo_v2.member.domain.LoginType; +import com.gamegoo.gamegoo_v2.member.domain.Member; +import com.gamegoo.gamegoo_v2.member.domain.Tier; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Slice; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@DataJpaTest +@ActiveProfiles("test") +@Import(QuerydslConfig.class) +class FriendRepositoryTest { + + @Autowired + private FriendRepository friendRepository; + + @Autowired + private TestEntityManager em; + + private static final int PAGE_SIZE = 10; + + private Member member; + + @BeforeEach + void setUp() { + member = createMember("test@gmail.com", "member"); + } + + @Nested + @DisplayName("친구 목록 조회") + class findFriendsByCursorAndOrderedTest { + + @DisplayName("친구 목록 조회: 친구가 0명인 경우") + @Test + void findFriendsByCursorAndOrderedWhenNoFriend() { + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), null, + PAGE_SIZE); + + // then + assertThat(friendSlice).isEmpty(); + assertFalse(friendSlice.hasNext()); + } + + @DisplayName("친구 목록 조회: 친구가 page size 이하이고 cursor로 null을 입력한 경우") + @Test + void findFriendsByCursorAndOrderedOnePage() { + // given + for (int i = 1; i <= 10; i++) { + Member toMember = createMember("member" + i + "@gmail.com", "member" + i); + createFriend(member, toMember); + } + + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), null, + PAGE_SIZE); + + // then + assertThat(friendSlice).hasSize(10); + assertThat(friendSlice.hasNext()).isEqualTo(false); + assertThat(friendSlice.getContent().get(0).getToMember().getGameName()).isEqualTo("member1"); + assertThat(friendSlice.getContent().get(9).getToMember().getGameName()).isEqualTo("member9"); + } + + @DisplayName("친구 목록 조회: 친구가 page size 이상이고 cursor로 null을 입력한 경우 첫 페이지를 조회해야 한다.") + @Test + void findFriendsByCursorAndOrderedFirstPage() { + // given + for (int i = 1; i <= 20; i++) { + Member toMember = createMember("member" + i + "@gmail.com", "member" + i); + createFriend(member, toMember); + } + + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), null, + PAGE_SIZE); + + // then + assertThat(friendSlice).hasSize(10); + assertThat(friendSlice.hasNext()).isEqualTo(true); + assertThat(friendSlice.getContent().get(0).getToMember().getGameName()).isEqualTo("member1"); + assertThat(friendSlice.getContent().get(9).getToMember().getGameName()).isEqualTo("member18"); + } + + @DisplayName("친구 목록 조회: 친구가 page size 이상이고 cursor를 정상 입력한 경우 다음 페이지를 조회해야 한다.") + @Test + void findFriendsByCursorAndOrderedNextPage() { + Long cursorId = 0L; + // given + for (int i = 1; i <= 20; i++) { + Member toMember = createMember("member" + i + "@gmail.com", "member" + i); + createFriend(member, toMember); + if (i == 18) { + cursorId = toMember.getId(); + } + } + + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), cursorId, + PAGE_SIZE); + + // then + assertThat(friendSlice).hasSize(10); + assertThat(friendSlice.hasNext()).isEqualTo(false); + assertThat(friendSlice.getContent().get(0).getToMember().getGameName()).isEqualTo("member19"); + assertThat(friendSlice.getContent().get(9).getToMember().getGameName()).isEqualTo("member9"); + } + + @DisplayName("친구 목록 조회: 친구가 page size 이상이고 cursor에 해당하는 회원이 없는 경우 첫 페이지를 조회해야 한다.") + @Test + void findFriendsByCursorAndOrderedFirstPageWhenNoCursorMember() { + // given + for (int i = 1; i <= 20; i++) { + Member toMember = createMember("member" + i + "@gmail.com", "member" + i); + createFriend(member, toMember); + } + Long cursorId = 100L; + + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), cursorId, + PAGE_SIZE); + + // then + assertThat(friendSlice).hasSize(10); + assertThat(friendSlice.hasNext()).isEqualTo(true); + assertThat(friendSlice.getContent().get(0).getToMember().getGameName()).isEqualTo("member1"); + assertThat(friendSlice.getContent().get(9).getToMember().getGameName()).isEqualTo("member18"); + } + + @DisplayName("친구 목록 조회: 조회 결과는 친구 회원의 소환사명에 대해 한>영>숫자 순으로 정렬되어야 한다.") + @Test + void findFriendsByCursorAndOrderedByGameName() { + // given + List gameNameList = Arrays.asList("가", "가1", "가2", "가10", "가a", "가가", "a", "가a1", "가aa", "123"); + for (int i = 0; i < gameNameList.size(); i++) { + Member toMember = createMember("member" + (i + 1) + "@gmail.com", gameNameList.get(i)); + createFriend(member, toMember); + } + + // when + Slice friendSlice = friendRepository.findFriendsByCursorAndOrdered(member.getId(), null, + PAGE_SIZE); + + // then + assertThat(friendSlice).hasSize(10); + assertThat(friendSlice.hasNext()).isEqualTo(false); + List orderedGameName = Arrays.asList("가", "가가", "가a", "가aa", "가a1", "가1", "가10", "가2", "a", "123"); + for (int i = 0; i < orderedGameName.size(); i++) { + assertThat(friendSlice.getContent().get(i).getToMember().getGameName()).isEqualTo(orderedGameName.get(i)); + } + } + + } + + private Member createMember(String email, String gameName) { + return em.persist(Member.builder() + .email(email) + .password("testPassword") + .profileImage(1) + .loginType(LoginType.GENERAL) + .gameName(gameName) + .tag("TAG") + .tier(Tier.IRON) + .gameRank(0) + .winRate(0.0) + .gameCount(0) + .isAgree(true) + .build()); + } + + private Friend createFriend(Member fromMember, Member toMember) { + friendRepository.save(Friend.create(toMember, fromMember)); + return friendRepository.save(Friend.create(fromMember, toMember)); + } + +}