diff --git a/build.gradle b/build.gradle index a537040b6..c007b4385 100644 --- a/build.gradle +++ b/build.gradle @@ -64,6 +64,10 @@ dependencies { //s3 버킷 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' + + // MapStruct + implementation 'org.mapstruct:mapstruct:1.5.5.Final' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/common/util/CursorBasedList.java b/src/main/java/konkuk/thip/common/util/CursorBasedList.java new file mode 100644 index 000000000..e1228e534 --- /dev/null +++ b/src/main/java/konkuk/thip/common/util/CursorBasedList.java @@ -0,0 +1,16 @@ +package konkuk.thip.common.util; + +import java.util.List; + +public record CursorBasedList( + List contents, + String nextCursor, + boolean hasNext +) { + public static CursorBasedList of(List queryList, int size, CursorExtractor extractor) { + boolean hasNext = queryList.size() > size; + List contents = hasNext ? queryList.subList(0, size) : queryList; + String nextCursor = hasNext ? extractor.extractCursor(contents.get(size - 1)) : null; + return new CursorBasedList<>(contents, nextCursor, hasNext); + } +} diff --git a/src/main/java/konkuk/thip/common/util/CursorExtractor.java b/src/main/java/konkuk/thip/common/util/CursorExtractor.java new file mode 100644 index 000000000..dc2b009fb --- /dev/null +++ b/src/main/java/konkuk/thip/common/util/CursorExtractor.java @@ -0,0 +1,6 @@ +package konkuk.thip.common.util; + +@FunctionalInterface +public interface CursorExtractor { + String extractCursor(T lastElement); +} diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index e09821ab4..e7b011ef2 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -33,6 +33,9 @@ public class UserCommandController { private final UserFollowUsecase userFollowUsecase; private final JwtUtil jwtUtil; + /** + * 사용자 회원가입 + */ @PostMapping("/users/signup") public BaseResponse signup(@Valid @RequestBody final UserSignupRequest request, @Oauth2Id final String oauth2Id, @@ -43,6 +46,9 @@ public BaseResponse signup(@Valid @RequestBody final UserSig return BaseResponse.ok(UserSignupResponse.of(userId)); } + /** + * 닉네임 중복 확인 + */ @PostMapping("/users/nickname") public BaseResponse verifyNickname(@Valid @RequestBody final UserVerifyNicknameRequest request) { return BaseResponse.ok(UserVerifyNicknameResponse.of( @@ -50,7 +56,9 @@ public BaseResponse verifyNickname(@Valid @RequestBo ); } - // 팔루우 상태 변경 : true -> 팔로우, false -> 언팔로우 + /** + * 사용자 팔로우 상태 변경 : true -> 팔로우, false -> 언팔로우 + */ @PostMapping("/users/following/{followingUserId}") public BaseResponse followUser(@UserId final Long userId, @PathVariable final Long followingUserId, diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java index 846abe69c..8a2454f17 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java @@ -1,9 +1,13 @@ package konkuk.thip.user.adapter.in.web; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; +import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; import konkuk.thip.user.adapter.in.web.response.UserViewAliasChoiceResponse; -import konkuk.thip.user.application.port.in.UserGetFollowersUsecase; +import konkuk.thip.user.application.port.in.UserGetFollowUsecase; import konkuk.thip.user.application.port.in.UserViewAliasChoiceUseCase; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; @@ -16,8 +20,11 @@ public class UserQueryController { private final UserViewAliasChoiceUseCase userViewAliasChoiceUseCase; - private final UserGetFollowersUsecase userGetFollowersUsecase; + private final UserGetFollowUsecase userGetFollowUsecase; + /** + * 사용자 별칭 선택 화면 조회 + */ @GetMapping("/users/alias") public BaseResponse showAliasChoiceView() { return BaseResponse.ok(UserViewAliasChoiceResponse.of( @@ -25,9 +32,23 @@ public BaseResponse showAliasChoiceView() { )); } + /** + * 사용자 팔로워 조회 + */ @GetMapping("/users/{userId}/followers") public BaseResponse showFollowers(@PathVariable final Long userId, - @RequestParam(required = false) final String cursor) { - return BaseResponse.ok(userGetFollowersUsecase.getUserFollowers(userId, cursor)); + @RequestParam(required = false) final String cursor, + @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { + return BaseResponse.ok(userGetFollowUsecase.getUserFollowers(userId, cursor, size)); + } + + /** + * 내 팔로잉 리스트 조회 + */ + @GetMapping("/users/my/following") + public BaseResponse showMyFollowing(@UserId final Long userId, + @RequestParam(required = false) final String cursor, + @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { + return BaseResponse.ok(userGetFollowUsecase.getMyFollowing(userId, cursor, size)); } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java index 9c923a4a9..32d5c907a 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowersResponse.java @@ -2,15 +2,12 @@ import lombok.Builder; -import java.time.LocalDateTime; import java.util.List; @Builder public record UserFollowersResponse( - List followerList, - int size, - LocalDateTime nextCursor, - boolean isFirst, + List followers, + String nextCursor, boolean isLast ) { @Builder @@ -21,6 +18,7 @@ public record Follower( String aliasName, Integer followerCount ){ + } } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java new file mode 100644 index 000000000..a099bb173 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserFollowingResponse.java @@ -0,0 +1,22 @@ +package konkuk.thip.user.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record UserFollowingResponse( + List followings, + String nextCursor, + boolean isLast +) { + @Builder + public record Following( + Long userId, + String nickname, + String profileImageUrl, + String aliasName + ){ + } + +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java index c457388a0..07767f6a7 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/FollowingJpaEntity.java @@ -21,9 +21,9 @@ public class FollowingJpaEntity extends BaseJpaEntity { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") - private UserJpaEntity userJpaEntity; + private UserJpaEntity userJpaEntity; // 팔로잉 하는 유저 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "following_user_id") - private UserJpaEntity followingUserJpaEntity; + private UserJpaEntity followingUserJpaEntity; // 팔로우 당하는 유저 } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java index 278f7e3c2..271528411 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java @@ -1,9 +1,8 @@ package konkuk.thip.user.adapter.out.persistence; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.common.util.DateUtil; -import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; -import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; import konkuk.thip.user.application.port.out.FollowingQueryPort; import lombok.RequiredArgsConstructor; @@ -19,39 +18,26 @@ public class FollowingQueryPersistenceAdapter implements FollowingQueryPort { private final FollowingJpaRepository followingJpaRepository; @Override - public UserFollowersResponse getFollowersByUserId(Long userId, String cursor, int size) { - LocalDateTime nextCursor = null; - if (cursor != null && !cursor.isBlank()) { - nextCursor = DateUtil.parseDateTime(cursor); - } - - List followerEntities = - followingJpaRepository.findFollowersByUserIdBeforeCreatedAt(userId, nextCursor, size); - - List followers = followerEntities.stream() - .map(FollowingJpaEntity::getUserJpaEntity) // 팔로워 사용자 - .toList(); - - List followerList = followers.stream() - .map(follower -> UserFollowersResponse.Follower.builder() - .userId(follower.getUserId()) - .nickname(follower.getNickname()) - .profileImageUrl(follower.getAliasForUserJpaEntity().getImageUrl()) - .aliasName(follower.getAliasForUserJpaEntity().getValue()) - .followerCount(follower.getFollowerCount()) - .build()) - .toList(); - - boolean isLast = followerEntities.size() < size; - nextCursor = isLast ? null : - followerEntities.get(followerEntities.size() - 1).getCreatedAt(); - - return UserFollowersResponse.builder() - .followerList(followerList) - .size(followerList.size()) - .nextCursor(nextCursor) - .isFirst(cursor == null) // cursor가 null이면 첫 페이지 - .isLast(isLast) - .build(); + public CursorBasedList getFollowersByUserId(Long userId, String cursor, int size) { + LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; + List followerDtos = followingJpaRepository.findFollowerDtosByUserIdBeforeCreatedAt( + userId, + cursorVal, + size + ); + + return CursorBasedList.of(followerDtos, size, followerDto -> followerDto.createdAt().toString()); + } + + @Override + public CursorBasedList getFollowingByUserId(Long userId, String cursor, int size) { + LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; + List followingDtos = followingJpaRepository.findFollowingDtosByUserIdBeforeCreatedAt( + userId, + cursorVal, + size + ); + + return CursorBasedList.of(followingDtos, size, followingDto -> followingDto.createdAt().toString()); } } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java index cfe0c1253..fac66996c 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java @@ -1,6 +1,7 @@ package konkuk.thip.user.adapter.out.persistence.repository.following; import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; import java.time.LocalDateTime; import java.util.List; @@ -9,5 +10,6 @@ public interface FollowingQueryRepository { Optional findByUserAndTargetUser(Long userId, Long targetUserId); - List findFollowersByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); + List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); + List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java index ec2a2a118..68dc546c8 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java @@ -7,6 +7,8 @@ import konkuk.thip.user.adapter.out.jpa.QAliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.out.dto.QFollowQueryDto; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; @@ -34,26 +36,55 @@ public Optional findByUserAndTargetUser(Long userId, Long ta } @Override - public List findFollowersByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + return findFollowDtos( + userId, + cursor, + size, + true // isFollowerQuery + ); + } + + @Override + public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + return findFollowDtos( + userId, + cursor, + size, + false // isFollowingQuery + ); + } + + private List findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery) { QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; QUserJpaEntity user = QUserJpaEntity.userJpaEntity; QAliasJpaEntity alias = QAliasJpaEntity.aliasJpaEntity; BooleanBuilder condition = new BooleanBuilder() - .and(following.followingUserJpaEntity.userId.eq(userId)) + .and((isFollowerQuery ? following.followingUserJpaEntity.userId.eq(userId) : following.userJpaEntity.userId.eq(userId))) .and(following.status.eq(StatusType.ACTIVE)); if (cursor != null) { condition.and(following.createdAt.lt(cursor)); } + QUserJpaEntity targetUser = isFollowerQuery ? following.userJpaEntity : following.followingUserJpaEntity; + return jpaQueryFactory - .selectFrom(following) - .leftJoin(following.userJpaEntity, user).fetchJoin() // N+1 문제 방지를 위해 fetchJoin - .leftJoin(user.aliasForUserJpaEntity, alias).fetchJoin() + .select(new QFollowQueryDto( + targetUser.userId, + targetUser.nickname, + alias.imageUrl, + alias.value, + targetUser.followerCount, + following.createdAt + )) + .from(following) + .leftJoin(targetUser, user) + .leftJoin(user.aliasForUserJpaEntity, alias) .where(condition) .orderBy(following.createdAt.desc()) - .limit(size) + .limit(size + 1) .fetch(); } } diff --git a/src/main/java/konkuk/thip/user/application/mapper/FollowDtoMapper.java b/src/main/java/konkuk/thip/user/application/mapper/FollowDtoMapper.java new file mode 100644 index 000000000..8c29f16b2 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/mapper/FollowDtoMapper.java @@ -0,0 +1,14 @@ +package konkuk.thip.user.application.mapper; + +import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; +import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import org.mapstruct.Mapper; + +@Mapper(componentModel = "spring") +public interface FollowDtoMapper { + + UserFollowersResponse.Follower toFollowerList(FollowQueryDto dto); + + UserFollowingResponse.Following toFollowingList(FollowQueryDto dto); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowUsecase.java b/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowUsecase.java new file mode 100644 index 000000000..d35e8313e --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowUsecase.java @@ -0,0 +1,10 @@ +package konkuk.thip.user.application.port.in; + +import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; +import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; + +public interface UserGetFollowUsecase { + UserFollowersResponse getUserFollowers(Long userId, String cursor, int size); + + UserFollowingResponse getMyFollowing(Long userId, String cursor, int size); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowersUsecase.java b/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowersUsecase.java deleted file mode 100644 index 673ad6793..000000000 --- a/src/main/java/konkuk/thip/user/application/port/in/UserGetFollowersUsecase.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.user.application.port.in; - -import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; - -public interface UserGetFollowersUsecase { - UserFollowersResponse getUserFollowers(Long userId, String nextCursor); -} diff --git a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java index cfaa74bc3..4b3651b98 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java @@ -1,8 +1,10 @@ package konkuk.thip.user.application.port.out; -import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; public interface FollowingQueryPort { - UserFollowersResponse getFollowersByUserId(Long userId, String cursor, int size); + CursorBasedList getFollowersByUserId(Long userId, String cursor, int size); + CursorBasedList getFollowingByUserId(Long userId, String cursor, int size); } diff --git a/src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java b/src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java new file mode 100644 index 000000000..0b88f7d54 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/out/dto/FollowQueryDto.java @@ -0,0 +1,24 @@ +package konkuk.thip.user.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import org.springframework.util.Assert; + +import java.time.LocalDateTime; + +public record FollowQueryDto(Long userId, + String nickname, + String profileImageUrl, + String aliasName, + Integer followerCount, + LocalDateTime createdAt) { + + @QueryProjection + public FollowQueryDto { + Assert.notNull(userId, "userId must not be null"); + Assert.notNull(nickname, "nickname must not be null"); + Assert.notNull(profileImageUrl, "profileImageUrl must not be null"); + Assert.notNull(aliasName, "aliasName must not be null"); +// Assert.notNull(followerCount, "followerCount must not be null"); // 내 팔로잉 목록 조회에서는 필요 x + Assert.notNull(createdAt, "createdAt must not be null"); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java new file mode 100644 index 000000000..24930a715 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java @@ -0,0 +1,66 @@ +package konkuk.thip.user.application.service.following; + +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; +import konkuk.thip.user.adapter.in.web.response.UserFollowingResponse; +import konkuk.thip.user.application.mapper.FollowDtoMapper; +import konkuk.thip.user.application.port.out.dto.FollowQueryDto; +import konkuk.thip.user.application.port.in.UserGetFollowUsecase; +import konkuk.thip.user.application.port.out.FollowingQueryPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserGetFollowService implements UserGetFollowUsecase { + + private final FollowingQueryPort followingQueryPort; + private final UserCommandPort userCommandPort; + + private final FollowDtoMapper followDtoMapper; + + private static final int MAX_PAGE_SIZE = 10; + + @Override + @Transactional(readOnly = true) + public UserFollowersResponse getUserFollowers(Long userId, String cursor, int size) { + User user = userCommandPort.findById(userId); + + CursorBasedList result = followingQueryPort.getFollowersByUserId( + user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) + ); + + var followers = result.contents().stream() + .map(followDtoMapper::toFollowerList) + .toList(); + + return UserFollowersResponse.builder() + .followers(followers) + .nextCursor(result.nextCursor()) + .isLast(!result.hasNext()) + .build(); + } + + @Override + @Transactional(readOnly = true) + public UserFollowingResponse getMyFollowing(Long userId, String cursor, int size) { + User user = userCommandPort.findById(userId); + + CursorBasedList result = followingQueryPort.getFollowingByUserId( + user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) + ); + + var following = result.contents().stream() + .map(followDtoMapper::toFollowingList) + .toList(); + + return UserFollowingResponse.builder() + .followings(following) + .nextCursor(result.nextCursor()) + .isLast(!result.hasNext()) + .build(); + } +} diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowersService.java b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowersService.java deleted file mode 100644 index 6752c7fbe..000000000 --- a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowersService.java +++ /dev/null @@ -1,27 +0,0 @@ -package konkuk.thip.user.application.service.following; - -import konkuk.thip.user.adapter.in.web.response.UserFollowersResponse; -import konkuk.thip.user.application.port.in.UserGetFollowersUsecase; -import konkuk.thip.user.application.port.out.FollowingQueryPort; -import konkuk.thip.user.application.port.out.UserCommandPort; -import konkuk.thip.user.domain.User; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Service -@RequiredArgsConstructor -public class UserGetFollowersService implements UserGetFollowersUsecase { - - private final FollowingQueryPort followingQueryPort; - private final UserCommandPort userCommandPort; - - private static final int DEFAULT_PAGE_SIZE = 10; - - @Override - @Transactional(readOnly = true) - public UserFollowersResponse getUserFollowers(Long userId, String cursor) { - User user = userCommandPort.findById(userId); - return followingQueryPort.getFollowersByUserId(user.getId(), cursor, DEFAULT_PAGE_SIZE); - } -} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowersApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowersApiTest.java index 64deb6889..8a59ec1f8 100644 --- a/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowersApiTest.java +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowersApiTest.java @@ -4,10 +4,9 @@ import konkuk.thip.common.util.TestEntityFactory; import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; -import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; -import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,6 +16,7 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.List; @@ -30,6 +30,7 @@ @ActiveProfiles("test") @AutoConfigureMockMvc(addFilters = false) @DisplayName("[통합] 팔로워 조회 API 통합 테스트") +@Transactional class UserGetFollowersApiTest { @Autowired @@ -66,13 +67,6 @@ void setUp() { } } - @AfterEach - void tearDown() { - followingJpaRepository.deleteAllInBatch(); - userJpaRepository.deleteAll(); - aliasJpaRepository.deleteAll(); - } - @Test @DisplayName("팔로워가 12명일 때 2페이지에 걸쳐 모두 조회되고 커서가 올바르게 작동한다.") void getFollowersWithCursorPaging() throws Exception { @@ -82,8 +76,7 @@ void getFollowersWithCursorPaging() throws Exception { ); firstPageResult.andExpect(status().isOk()) - .andExpect(jsonPath("$.data.followerList", hasSize(10))) - .andExpect(jsonPath("$.data.isFirst").value(true)) + .andExpect(jsonPath("$.data.followers", hasSize(10))) .andExpect(jsonPath("$.data.isLast").value(false)) .andExpect(jsonPath("$.data.nextCursor").exists()); @@ -101,8 +94,7 @@ void getFollowersWithCursorPaging() throws Exception { ); secondPageResult.andExpect(status().isOk()) - .andExpect(jsonPath("$.data.followerList", hasSize(2))) - .andExpect(jsonPath("$.data.isFirst").value(false)) + .andExpect(jsonPath("$.data.followers", hasSize(2))) .andExpect(jsonPath("$.data.isLast").value(true)) .andExpect(jsonPath("$.data.nextCursor").doesNotExist()); } diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowingApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowingApiTest.java new file mode 100644 index 000000000..b19215dec --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetFollowingApiTest.java @@ -0,0 +1,103 @@ +package konkuk.thip.user.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.alias.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 내 팔로잉 리스트 조회 API 통합 테스트") +@Transactional +class UserGetFollowingApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private FollowingJpaRepository followingJpaRepository; + + private UserJpaEntity loginUser; // 팔로잉 리스트를 조회하는 로그인한 사용자 + private List followingUsers; // 팔로잉 12명 + + @BeforeEach + void setUp() { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + // 로그인 사용자 + loginUser = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // 팔로잉 12명 생성 및 저장 + followingUsers = new ArrayList<>(); + for (int i = 0; i < 12; i++) { + UserJpaEntity followingUser = userJpaRepository.save(TestEntityFactory.createUser(alias)); + followingUsers.add(followingUser); + followingJpaRepository.save(TestEntityFactory.createFollowing(loginUser, followingUser)); + } + } + + @Test + @DisplayName("로그인한 사용자의 팔로잉 수가 12명일 때 2페이지에 걸쳐 모두 조회되고 커서가 올바르게 작동한다.") + void getFollowingsWithCursorPaging() throws Exception { + // 1. 첫 번째 요청 (cursor 없음) + ResultActions firstPageResult = mockMvc.perform( + get("/users/my/following") + .requestAttr("userId", loginUser.getUserId()) + ); + + firstPageResult.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.followings", hasSize(10))) + .andExpect(jsonPath("$.data.isLast").value(false)) + .andExpect(jsonPath("$.data.nextCursor").exists()); + + // 커서 추출 + String responseBody = firstPageResult.andReturn().getResponse().getContentAsString(); + String nextCursor = objectMapper.readTree(responseBody) + .path("data") + .path("nextCursor") + .asText(); + + // 2. 두 번째 요청 (cursor 사용) + ResultActions secondPageResult = mockMvc.perform( + get("/users/my/following") + .param("cursor", nextCursor) + .requestAttr("userId", loginUser.getUserId()) + ); + + secondPageResult.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.followings", hasSize(2))) + .andExpect(jsonPath("$.data.isLast").value(true)) + .andExpect(jsonPath("$.data.nextCursor").doesNotExist()); + } +} \ No newline at end of file