diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index c2c54d8ac..760675ece 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -117,7 +117,9 @@ public enum ErrorCode implements ResponseCode { USER_NOT_PARTICIPATED_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, 140006, "사용자가 방에 참여하지 않은 상태에서 취소하기는 불가합니다."), HOST_CANNOT_CANCEL(HttpStatus.BAD_REQUEST, 140007, "방장은 참여 취소를 할 수 없습니다."), ROOM_RECRUIT_CANNOT_CLOSED(HttpStatus.BAD_REQUEST, 140008, "방 모집 마감을 할 수 없습니다."), - ROOM_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 140009, "방 접근 권한이 없습니다."), + INVALID_MY_ROOM_TYPE(HttpStatus.BAD_REQUEST, 140009, "유저가 참가한 방 목록 검색 요청에 유효하지 않은 MY ROOM type 이 있습니다."), + INVALID_MY_ROOM_CURSOR(HttpStatus.BAD_REQUEST, 140010, "유저가 참가한 방 목록 검색 요청에 유효하지 않은 cursor 가 있습니다"), + ROOM_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 140011, "방 접근 권한이 없습니다."), /** * 150000 : Category error diff --git a/src/main/java/konkuk/thip/common/util/Cursor.java b/src/main/java/konkuk/thip/common/util/Cursor.java index 25b92273f..1ddbfa55e 100644 --- a/src/main/java/konkuk/thip/common/util/Cursor.java +++ b/src/main/java/konkuk/thip/common/util/Cursor.java @@ -5,6 +5,7 @@ import java.net.URLDecoder; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; @@ -51,6 +52,10 @@ public LocalDateTime getLocalDateTime(int index) { return getAs(index, LocalDateTime::parse, "LocalDateTime"); } + public LocalDate getLocalDate(int index) { + return getAs(index, LocalDate::parse, "LocalDate"); + } + public Long getLong(int index) { return getAs(index, Long::parseLong, "Long"); } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java index d185ed994..dd36b6f7e 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java @@ -2,11 +2,7 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; -import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; -import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; -import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; -import konkuk.thip.room.adapter.in.web.response.RoomGetMemberListResponse; -import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.adapter.in.web.response.*; import konkuk.thip.room.application.port.in.*; import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; import konkuk.thip.room.application.port.in.RoomGetMemberListUseCase; @@ -20,6 +16,8 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + @RestController @RequiredArgsConstructor public class RoomQueryController { @@ -30,6 +28,7 @@ public class RoomQueryController { private final RoomShowRecruitingDetailViewUseCase roomShowRecruitingDetailViewUseCase; private final RoomGetMemberListUseCase roomGetMemberListUseCase; private final RoomShowPlayingDetailViewUseCase roomShowPlayingDetailViewUseCase; + private final RoomShowMineUseCase roomShowMineUseCase; @GetMapping("/rooms/search") public BaseResponse searchRooms( @@ -82,4 +81,12 @@ public BaseResponse getPlayingRoomDetailView( return BaseResponse.ok(roomShowPlayingDetailViewUseCase.getPlayingRoomDetailView(userId, roomId)); } + // 내 모임방 리스트 조회 + @GetMapping("/rooms/my") + public BaseResponse getMyRooms( + @UserId final Long userId, + @RequestParam(value = "type", required = false, defaultValue = "playingAndRecruiting") final String type, + @RequestParam(value = "cursor", required = false) final String cursor) { + return BaseResponse.ok(roomShowMineUseCase.getMyRooms(userId, type, cursor)); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomShowMineResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomShowMineResponse.java new file mode 100644 index 000000000..110292c61 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomShowMineResponse.java @@ -0,0 +1,17 @@ +package konkuk.thip.room.adapter.in.web.response; + +import java.util.List; + +public record RoomShowMineResponse( + List roomList, + String nextCursor, + boolean isLast +) { + public record MyRoom( + Long roomId, + String bookImageUrl, + String roomName, + int memberCount, + String endDate // 방 진행 마감일 or 방 모집 마감일 (~ 뒤 형식) + ) {} +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java index 8c7128b06..11f418b28 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java @@ -69,4 +69,9 @@ public RoomJpaEntity updateFrom(Room room) { this.memberCount = room.getMemberCount(); return this; } -} \ No newline at end of file + + // 테스트 메서드 편의용 + public void updateMemberCount(int memberCount) { + this.memberCount = memberCount; + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java index 72291caf6..7f3139f60 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java @@ -1,11 +1,14 @@ package konkuk.thip.room.adapter.out.persistence; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; import konkuk.thip.room.adapter.out.mapper.RoomMapper; import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; import konkuk.thip.room.domain.Room; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -41,4 +44,44 @@ public List findOtherRecruitingR public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { return roomJpaRepository.searchHomeJoinedRooms(userId, date, pageable); } + + @Override + public CursorBasedList findRecruitingRoomsUserParticipated(Long userId, Cursor cursor) { + return findRooms(userId, cursor, roomJpaRepository::findRecruitingRoomsUserParticipated); + } + + @Override + public CursorBasedList findPlayingRoomsUserParticipated(Long userId, Cursor cursor) { + return findRooms(userId, cursor, roomJpaRepository::findPlayingRoomsUserParticipated); + } + + @Override + public CursorBasedList findPlayingAndRecruitingRoomsUserParticipated(Long userId, Cursor cursor) { + return findRooms(userId, cursor, roomJpaRepository::findPlayingAndRecruitingRoomsUserParticipated); + } + + @Override + public CursorBasedList findExpiredRoomsUserParticipated(Long userId, Cursor cursor) { + return findRooms(userId, cursor, roomJpaRepository::findExpiredRoomsUserParticipated); + } + + @FunctionalInterface + private interface RoomQueryFunction { + List apply(Long userId, LocalDate lastLocalDate, Long lastId, int pageSize); + } + + private CursorBasedList findRooms(Long userId, Cursor cursor, RoomQueryFunction queryFunction) { + LocalDate lastLocalDate = cursor.isFirstRequest() ? null : cursor.getLocalDate(0); + Long lastId = cursor.isFirstRequest() ? null : cursor.getLong(1); + int pageSize = cursor.getPageSize(); + + List dtos = queryFunction.apply(userId, lastLocalDate, lastId, pageSize); + return CursorBasedList.of(dtos, pageSize, roomShowMineQueryDto -> { + Cursor nextCursor = new Cursor(List.of( + roomShowMineQueryDto.endDate().toString(), + roomShowMineQueryDto.roomId().toString() + )); + return nextCursor.toEncodedString(); + }); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java index 7580ae787..097d8dbef 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java @@ -3,6 +3,7 @@ import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,5 +16,14 @@ public interface RoomQueryRepository { Page searchRoom(String keyword, String category, Pageable pageable); List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, String category, int count); + Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); + + List findRecruitingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); + + List findPlayingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); + + List findPlayingAndRecruitingRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); + + List findExpiredRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java index 84894eb6f..42fccc107 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java @@ -4,23 +4,25 @@ import com.querydsl.core.Tuple; import com.querydsl.core.types.Order; import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.DateExpression; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.JPQLQuery; import com.querydsl.jpa.impl.JPAQueryFactory; import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; +import konkuk.thip.common.entity.StatusType; import konkuk.thip.common.util.DateUtil; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; import konkuk.thip.room.adapter.out.jpa.QRoomJpaEntity; import konkuk.thip.room.adapter.out.jpa.QRoomParticipantJpaEntity; +import konkuk.thip.room.application.port.out.dto.QRoomShowMineQueryDto; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageImpl; -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; +import org.springframework.data.domain.*; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -34,8 +36,7 @@ public class RoomQueryRepositoryImpl implements RoomQueryRepository { private final JPAQueryFactory queryFactory; private final QRoomJpaEntity room = QRoomJpaEntity.roomJpaEntity; private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; - private final QRoomParticipantJpaEntity userRoom = QRoomParticipantJpaEntity.roomParticipantJpaEntity; - + private final QRoomParticipantJpaEntity participant = QRoomParticipantJpaEntity.roomParticipantJpaEntity; @Override public Page searchRoom(String keyword, String category, Pageable pageable) { @@ -58,7 +59,7 @@ public Page searchRoom(String keyword, Stri .when(room.title.containsIgnoreCase(keyword)).then(1) .otherwise(0); - NumberExpression memberCountExpr = userRoom.roomParticipantId.count(); // 방 별 멤버수 표현식 + NumberExpression memberCountExpr = participant.roomParticipantId.count(); // 방 별 멤버수 표현식 List tuples = queryFactory .select( @@ -72,7 +73,7 @@ public Page searchRoom(String keyword, Stri ) .from(room) .join(room.bookJpaEntity, book) - .leftJoin(userRoom).on(userRoom.roomJpaEntity.eq(room)) + .leftJoin(participant).on(participant.roomJpaEntity.eq(room)) .where(where) .groupBy( room.roomId, @@ -146,11 +147,11 @@ private OrderSpecifier toOrderSpecifier(Sort sort, QRoomJpaEntity room, Numbe @Override public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, String category, int count) { - NumberExpression memberCountExpr = userRoom.roomParticipantId.count(); + NumberExpression memberCountExpr = participant.roomParticipantId.count(); List tuples = queryFactory .select(room.roomId, room.title, memberCountExpr, room.recruitCount, room.startDate) .from(room) - .leftJoin(userRoom).on(userRoom.roomJpaEntity.eq(room)) + .leftJoin(participant).on(participant.roomJpaEntity.eq(room)) .where( room.categoryJpaEntity.value.eq(category) .and(room.startDate.after(LocalDate.now())) // 모집 마감 시각 > 현재 시각 @@ -181,7 +182,7 @@ public Page searchHomeJoinedRoom // 유저가 참여한 방만: userId 조건 // 활동 기간 중인 방만: startDate ≤ today ≤ endDate BooleanBuilder where = new BooleanBuilder(); - where.and(userRoom.userJpaEntity.userId.eq(userId)); + where.and(participant.userJpaEntity.userId.eq(userId)); where.and(room.startDate.loe(date)); where.and(room.endDate.goe(date)); @@ -202,14 +203,14 @@ public Page searchHomeJoinedRoom room.recruitCount, room.startDate, book.title, - userRoom.userPercentage + participant.userPercentage ) - .from(userRoom) - .join(userRoom.roomJpaEntity, room) + .from(participant) + .join(participant.roomJpaEntity, room) .join(room.bookJpaEntity, book) .where(where) .orderBy( - userRoom.userPercentage.desc(), // 진행률 높은 순(내림차순) + participant.userPercentage.desc(), // 진행률 높은 순(내림차순) room.startDate.asc() // 진행률 같으면 활동 시작일 빠른 순 (오름차순) ) .offset(pageable.getOffset()) @@ -224,7 +225,7 @@ public Page searchHomeJoinedRoom .bookImageUrl(t.get(book.imageUrl)) .bookTitle(t.get(book.title)) .memberCount(Optional.ofNullable(t.get(memberCountSubQuery)).map(Number::intValue).orElse(1)) - .userPercentage(Optional.ofNullable(t.get(userRoom.userPercentage)) + .userPercentage(Optional.ofNullable(t.get(participant.userPercentage)) .map(val -> ((Number) val).doubleValue()) .map(Math::round) .map(Long::intValue) @@ -235,9 +236,9 @@ public Page searchHomeJoinedRoom // 4. 전체 개수 조회 (페이징 정보 계산용) Long totalCount = queryFactory - .select(userRoom.count()) - .from(userRoom) - .join(userRoom.roomJpaEntity, room) + .select(participant.count()) + .from(participant) + .join(participant.roomJpaEntity, room) .where(where) .fetchOne(); long total = (totalCount != null) ? totalCount : 0L; @@ -245,4 +246,131 @@ public Page searchHomeJoinedRoom // 5. PageImpl 생성하여 반환 return new PageImpl<>(content, pageable, total); } + + // 1) 모집중인 방 + @Override + public List findRecruitingRoomsUserParticipated( + Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize + ) { + LocalDate today = LocalDate.now(); + BooleanExpression base = participant.userJpaEntity.userId.eq(userId) + .and(room.startDate.after(today)) + .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 모집중인 방 + DateExpression cursorExpr = room.startDate; // 커서 비교는 startDate(= 모집 마감일 - 1일) + OrderSpecifier[] orders = new OrderSpecifier[]{ + cursorExpr.asc(), room.roomId.asc() + }; + + return fetchMyRooms(base, cursorExpr, orders, true, dateCursor, roomIdCursor, pageSize); + } + + // 2) 진행중인 방 + @Override + public List findPlayingRoomsUserParticipated( + Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize + ) { + LocalDate today = LocalDate.now(); + BooleanExpression base = participant.userJpaEntity.userId.eq(userId) + .and(room.startDate.loe(today)) + .and(room.endDate.goe(today)) + .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 + DateExpression cursorExpr = room.endDate; // 커서 비교는 endDate(= 진행 마감일) + OrderSpecifier[] orders = new OrderSpecifier[]{ + cursorExpr.asc(), room.roomId.asc() + }; + + return fetchMyRooms(base, cursorExpr, orders, true, dateCursor, roomIdCursor, pageSize); + } + + // 3) 진행+모집 통합 + @Override + public List findPlayingAndRecruitingRoomsUserParticipated( + Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize + ) { + LocalDate today = LocalDate.now(); + BooleanExpression playing = room.startDate.loe(today).and(room.endDate.goe(today)); + BooleanExpression recruiting = room.startDate.after(today); + BooleanExpression base = participant.userJpaEntity.userId.eq(userId) + .and(playing.or(recruiting)) + .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 현재 진행중인 방 + 모집중인 방 + + // 진행중: cursor=endDate, 모집중: cursor=startDate + DateExpression cursorExpr = new CaseBuilder() + .when(playing).then(room.endDate) + .otherwise(room.startDate); + + // 진행중 먼저(0), 모집중 다음(1) -> 조회 우선순위 반영 + NumberExpression priority = new CaseBuilder() + .when(playing).then(0) + .otherwise(1); + + OrderSpecifier[] orders = new OrderSpecifier[]{ + priority.asc(), + cursorExpr.asc(), + room.roomId.asc() + }; + + return fetchMyRooms(base, cursorExpr, orders, true, dateCursor, roomIdCursor, pageSize); + } + + // 4) 만료된 방 + @Override + public List findExpiredRoomsUserParticipated( + Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize + ) { + LocalDate today = LocalDate.now(); + BooleanExpression base = participant.userJpaEntity.userId.eq(userId) + .and(room.endDate.before(today)) + .and(room.status.eq(StatusType.ACTIVE)); // 유저가 참여한 방 && 만료된 방 + + DateExpression cursorExpr = room.endDate; + OrderSpecifier[] orders = new OrderSpecifier[]{ + cursorExpr.desc(), room.roomId.desc() // 만료된 방은 가장 최근에 만료된 방부터 반환 + }; + + return fetchMyRooms(base, cursorExpr, orders, false, dateCursor, roomIdCursor, pageSize); + } + + /** + * 공통 커서 + 2단계 조회 (IDs → entities) 처리 + */ + private List fetchMyRooms( + BooleanExpression baseCondition, + DateExpression cursorExpr, + OrderSpecifier[] orders, + boolean ascending, + LocalDate dateCursor, + Long roomIdCursor, + int pageSize + ) { + BooleanBuilder where = new BooleanBuilder(baseCondition); + if (dateCursor != null && roomIdCursor != null) { // 첫 페이지가 아닌 경우 + if (ascending) { + where.and(cursorExpr.gt(dateCursor) + .or(cursorExpr.eq(dateCursor) + .and(room.roomId.gt(roomIdCursor)))); + } else { + where.and(cursorExpr.lt(dateCursor) + .or(cursorExpr.eq(dateCursor) + .and(room.roomId.lt(roomIdCursor)))); + } + } + + // 2) DTO 프로젝션: 필요한 필드만 바로 조회 + return queryFactory + .select(new QRoomShowMineQueryDto( + room.roomId, + book.imageUrl, + room.title, + room.memberCount, + cursorExpr + )) + .from(participant) + .join(participant.roomJpaEntity, room) + .leftJoin(room.bookJpaEntity, book) + .where(where) + .orderBy(orders) + .limit(pageSize + 1) + .fetch(); + } } diff --git a/src/main/java/konkuk/thip/room/application/mapper/RoomQueryMapper.java b/src/main/java/konkuk/thip/room/application/mapper/RoomQueryMapper.java new file mode 100644 index 000000000..0c80e94a8 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/mapper/RoomQueryMapper.java @@ -0,0 +1,22 @@ +package konkuk.thip.room.application.mapper; + +import konkuk.thip.common.util.DateUtil; +import konkuk.thip.room.adapter.in.web.response.RoomShowMineResponse; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + imports = DateUtil.class, + unmappedTargetPolicy = ReportingPolicy.IGNORE // 명시적으로 매핑하지 않은 필드를 무시하도록 설정 +) +public interface RoomQueryMapper { + + @Mapping( + target = "endDate", + expression = "java(DateUtil.formatAfterTime(dto.endDate()))" + ) + RoomShowMineResponse.MyRoom toShowMyRoomResponse(RoomShowMineQueryDto dto); +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomShowMineUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomShowMineUseCase.java new file mode 100644 index 000000000..4a7bbffed --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomShowMineUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.adapter.in.web.response.RoomShowMineResponse; + +public interface RoomShowMineUseCase { + + RoomShowMineResponse getMyRooms(Long userId, String type, String cursor); +} diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java index b5032e3de..85276c896 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java @@ -1,8 +1,11 @@ package konkuk.thip.room.application.port.out; +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; import konkuk.thip.room.domain.Room; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -11,9 +14,20 @@ import java.util.List; public interface RoomQueryPort { + int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate); + Page searchRoom(String keyword, String category, Pageable pageable); List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count); + Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); + + CursorBasedList findRecruitingRoomsUserParticipated(Long userId, Cursor cursor); + + CursorBasedList findPlayingRoomsUserParticipated(Long userId, Cursor cursor); + + CursorBasedList findPlayingAndRecruitingRoomsUserParticipated(Long userId, Cursor cursor); + + CursorBasedList findExpiredRoomsUserParticipated(Long userId, Cursor cursor); } diff --git a/src/main/java/konkuk/thip/room/application/port/out/dto/CursorSliceOfMyRoomView.java b/src/main/java/konkuk/thip/room/application/port/out/dto/CursorSliceOfMyRoomView.java new file mode 100644 index 000000000..1bc2af9cd --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/out/dto/CursorSliceOfMyRoomView.java @@ -0,0 +1,22 @@ +package konkuk.thip.room.application.port.out.dto; + +import lombok.Getter; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.SliceImpl; + +import java.time.LocalDate; +import java.util.List; + +@Getter +public class CursorSliceOfMyRoomView extends SliceImpl { + + private final LocalDate nextCursorDate; + private final Long nextCursorId; + + public CursorSliceOfMyRoomView(List content, Pageable pageable, boolean hasNext, + LocalDate nextCursorDate, Long nextCursorId) { + super(content, pageable, hasNext); + this.nextCursorDate = nextCursorDate; + this.nextCursorId = nextCursorId; + } +} diff --git a/src/main/java/konkuk/thip/room/application/port/out/dto/RoomShowMineQueryDto.java b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomShowMineQueryDto.java new file mode 100644 index 000000000..3d9bf21be --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/out/dto/RoomShowMineQueryDto.java @@ -0,0 +1,18 @@ +package konkuk.thip.room.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import lombok.Builder; + +import java.time.LocalDate; + +@Builder +public record RoomShowMineQueryDto( + Long roomId, + String bookImageUrl, + String roomName, + int memberCount, + LocalDate endDate // 방 진행 마감일 or 방 모집 마감일 +) { + @QueryProjection + public RoomShowMineQueryDto {} +} diff --git a/src/main/java/konkuk/thip/room/application/service/RoomShowMineService.java b/src/main/java/konkuk/thip/room/application/service/RoomShowMineService.java new file mode 100644 index 000000000..4fb6f9aa7 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomShowMineService.java @@ -0,0 +1,69 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.room.adapter.in.web.response.RoomShowMineResponse; +import konkuk.thip.room.application.mapper.RoomQueryMapper; +import konkuk.thip.room.application.port.in.RoomShowMineUseCase; +import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.room.application.port.out.dto.RoomShowMineQueryDto; +import konkuk.thip.room.domain.MyRoomType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RoomShowMineService implements RoomShowMineUseCase { + + private final static int PAGE_SIZE = 10; + + private final RoomQueryPort roomQueryPort; + private final RoomQueryMapper roomQueryMapper; + + @Override + @Transactional(readOnly = true) + public RoomShowMineResponse getMyRooms(Long userId, String type, String cursor) { + // 1. cursor 생성 + Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); + + // 2. type 검증 및 커서 기반 조회 + MyRoomType myRoomType = MyRoomType.from(type); + CursorBasedList result = switch (myRoomType) { + case RECRUITING -> roomQueryPort + .findRecruitingRoomsUserParticipated(userId, nextCursor); + case PLAYING -> roomQueryPort + .findPlayingRoomsUserParticipated(userId, nextCursor); + case PLAYING_AND_RECRUITING -> roomQueryPort + .findPlayingAndRecruitingRoomsUserParticipated(userId, nextCursor); + case EXPIRED -> roomQueryPort + .findExpiredRoomsUserParticipated(userId, nextCursor); + }; + + // 3. dto -> response로 매핑 (EXPIRED 타입인 경우 endDate를 null로 처리) + boolean isExpiredType = myRoomType == MyRoomType.EXPIRED; + List myRooms = result.contents().stream() + .map(dto -> { + var myRoomResponse = roomQueryMapper.toShowMyRoomResponse(dto); + if (isExpiredType) { + return new RoomShowMineResponse.MyRoom( + myRoomResponse.roomId(), + myRoomResponse.bookImageUrl(), + myRoomResponse.roomName(), + myRoomResponse.memberCount(), + null + ); + } + return myRoomResponse; + }) + .toList(); + + return new RoomShowMineResponse( + myRooms, + result.nextCursor(), + !result.hasNext() + ); + } +} diff --git a/src/main/java/konkuk/thip/room/domain/MyRoomType.java b/src/main/java/konkuk/thip/room/domain/MyRoomType.java new file mode 100644 index 000000000..7660c5491 --- /dev/null +++ b/src/main/java/konkuk/thip/room/domain/MyRoomType.java @@ -0,0 +1,28 @@ +package konkuk.thip.room.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum MyRoomType { + PLAYING("playing"), + RECRUITING("recruiting"), + PLAYING_AND_RECRUITING("playingAndRecruiting"), + EXPIRED("expired"); + + private final String type; + + public static MyRoomType from(String type) { + return Arrays.stream(MyRoomType.values()) + .filter(param -> param.getType().equals(type)) + .findFirst() + .orElseThrow( + () -> new InvalidStateException(ErrorCode.INVALID_MY_ROOM_TYPE) + ); + } +} diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java new file mode 100644 index 000000000..97de3c849 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomShowMineApiTest.java @@ -0,0 +1,463 @@ +package konkuk.thip.room.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantJpaEntity; +import konkuk.thip.room.adapter.out.jpa.RoomParticipantRole; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.category.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +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 org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.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 java.time.LocalDate; + +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_MY_ROOM_CURSOR; +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_MY_ROOM_TYPE; +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; +import static org.hamcrest.Matchers.*; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 내 방 목록 조회 api 통합 테스트") +class RoomShowMineApiTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private CategoryJpaRepository categoryJpaRepository; + + @Autowired + private BookJpaRepository bookJpaRepository; + + @Autowired + private RoomJpaRepository roomJpaRepository; + + @Autowired + private RoomParticipantJpaRepository roomParticipantJpaRepository; + + @AfterEach + void tearDown() { + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + categoryJpaRepository.deleteAllInBatch(); + aliasJpaRepository.deleteAllInBatch(); + } + + private RoomJpaEntity saveScienceRoom(String bookTitle, String isbn, String roomName, LocalDate startDate, LocalDate endDate, int recruitCount) { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .title(bookTitle) + .isbn(isbn) + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(300) + .description("한강의 소설") + .build()); + + CategoryJpaEntity category = categoryJpaRepository.save(TestEntityFactory.createScienceCategory(alias)); + + return roomJpaRepository.save(RoomJpaEntity.builder() + .title(roomName) + .description("한강 작품 읽기 모임") + .isPublic(true) + .roomPercentage(0.0) + .startDate(startDate) + .endDate(endDate) + .recruitCount(recruitCount) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build()); + } + + private void changeRoomMemberCount(RoomJpaEntity roomJpaEntity, int count) { + roomJpaEntity.updateMemberCount(count); + roomJpaRepository.save(roomJpaEntity); + } + + private void saveSingleUserToRoom(RoomJpaEntity roomJpaEntity, UserJpaEntity userJpaEntity) { + RoomParticipantJpaEntity roomParticipantJpaEntity = RoomParticipantJpaEntity.builder() + .userJpaEntity(userJpaEntity) + .roomJpaEntity(roomJpaEntity) + .roomParticipantRole(RoomParticipantRole.MEMBER) + .build(); + roomParticipantJpaRepository.save(roomParticipantJpaEntity); + + roomJpaEntity.updateMemberCount(roomJpaEntity.getMemberCount() + 1); + roomJpaRepository.save(roomJpaEntity); // room의 memberCount 값도 업데이트 해줘야 한다 + } + + @Test + @DisplayName("type 으로 playing 을 받을 경우, 해당 유저가 참여중인 방 중 [현재 진행중인 모임방]의 정보를 [활동 마감일 임박순] 으로 반환한다.") + void get_my_playing_rooms() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity playingRoom1 = saveScienceRoom("진행중인방-책-1", "isbn2", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playingRoom1, 6); + + RoomJpaEntity playingRoom2 = saveScienceRoom("진행중인방-책-2", "isbn3", "과학-방-10일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(10), 10); + changeRoomMemberCount(playingRoom2, 3); + + RoomJpaEntity expiredRoom1 = saveScienceRoom("만료된방-책-1", "isbn4", "과학-방-5일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(5), 10); + changeRoomMemberCount(expiredRoom1, 7); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(playingRoom1, user); + saveSingleUserToRoom(playingRoom2, user); + saveSingleUserToRoom(expiredRoom1, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "playing")); // 진행중인 방 + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-5일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[0].memberCount", is(7))) // 기존 6명 + user + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-10일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[1].memberCount", is(4))); // 기존 3명 + user + } + + @Test + @DisplayName("type 으로 recruiting 을 받을 경우, 해당 유저가 참여중인 방 중 [모집중인 모임방]의 정보를 [모집 마감일 임박순] 으로 반환한다.") + void get_my_recruiting_rooms() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity recruitingRoom2 = saveScienceRoom("모집중인방-책-2", "isbn2", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom2, 8); + + RoomJpaEntity playingRoom1 = saveScienceRoom("진행중인방-책-1", "isbn3", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playingRoom1, 6); + + RoomJpaEntity expiredRoom1 = saveScienceRoom("만료된방-책-1", "isbn4", "과학-방-5일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(5), 10); + changeRoomMemberCount(expiredRoom1, 7); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(recruitingRoom2, user); + saveSingleUserToRoom(playingRoom1, user); + saveSingleUserToRoom(expiredRoom1, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "recruiting")); // 모집중인 방 + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[0].memberCount", is(6))) // 기존 5명 + user + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].memberCount", is(9))); // 기존 8명 + user + } + + @Test + @DisplayName("type 이 주어지지 않을 경우, 해당 유저가 참여중인 방 중 [현재 진행중 + 모집중인 모임방]의 정보를 [현재 진행중 -> 모집중] 순 으로 반환한다.") + void get_my_playing_and_recruiting_rooms() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity recruitingRoom2 = saveScienceRoom("모집중인방-책-2", "isbn2", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom2, 8); + + RoomJpaEntity playingRoom1 = saveScienceRoom("진행중인방-책-1", "isbn3", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playingRoom1, 6); + + RoomJpaEntity expiredRoom1 = saveScienceRoom("만료된방-책-1", "isbn4", "과학-방-5일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(5), 10); + changeRoomMemberCount(expiredRoom1, 7); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(recruitingRoom2, user); + saveSingleUserToRoom(playingRoom1, user); + saveSingleUserToRoom(expiredRoom1, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId())); // type request param 없는 경우 + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(3))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-5일뒤-활동마감"))) + .andExpect(jsonPath("$.data.roomList[0].memberCount", is(7))) // 기존 6명 + user + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].memberCount", is(6))) // 기존 5명 + user + .andExpect(jsonPath("$.data.roomList[2].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[2].memberCount", is(9))); // 기존 8명 + user + } + + @Test + @DisplayName("type 으로 expired 을 받을 경우, 해당 유저가 참여중인 방 중 [만료된 모임방]의 정보를 [활동 마감일 최신순] 으로 반환한다.") + void get_my_expired_rooms() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity playingRoom1 = saveScienceRoom("진행중인방-책-1", "isbn2", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playingRoom1, 6); + + RoomJpaEntity expiredRoom1 = saveScienceRoom("만료된방-책-1", "isbn3", "과학-방-5일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(5), 10); + changeRoomMemberCount(expiredRoom1, 7); + + RoomJpaEntity expiredRoom2 = saveScienceRoom("만료된방-책-2", "isbn4", "과학-방-10일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(10), 10); + changeRoomMemberCount(expiredRoom2, 1); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(playingRoom1, user); + saveSingleUserToRoom(expiredRoom1, user); + saveSingleUserToRoom(expiredRoom2, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "expired")); // 만료된 방 + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-5일전-활동마감"))) + .andExpect(jsonPath("$.data.roomList[0].memberCount", is(8))) // 기존 7명 + user + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-10일전-활동마감"))) + .andExpect(jsonPath("$.data.roomList[1].memberCount", is(2))); // 기존 1명 + user + } + + @Test + @DisplayName("유효하지 않은 type 을 받을 경우, 400 error 를 반환한다.") + void get_my_rooms_wrong_type() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity recruitingRoom2 = saveScienceRoom("모집중인방-책-2", "isbn2", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom2, 8); + + RoomJpaEntity playingRoom1 = saveScienceRoom("진행중인방-책-1", "isbn3", "과학-방-5일뒤-활동마감", LocalDate.now().minusDays(5), LocalDate.now().plusDays(5), 10); + changeRoomMemberCount(playingRoom1, 6); + + RoomJpaEntity expiredRoom1 = saveScienceRoom("만료된방-책-1", "isbn4", "과학-방-5일전-활동마감", LocalDate.now().minusDays(30), LocalDate.now().minusDays(5), 10); + changeRoomMemberCount(expiredRoom1, 7); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(recruitingRoom2, user); + saveSingleUserToRoom(playingRoom1, user); + saveSingleUserToRoom(expiredRoom1, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "wrongType")); // 이상한 type request param + + //then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess", is(false))) + .andExpect(jsonPath("$.code", is(INVALID_MY_ROOM_TYPE.getCode()))); + } + + @Test + @DisplayName("한번에 최대 10개의 데이터만을 반환한다. 다음 페이지에 해당하는 데이터가 있을 경우, 다음 페이지의 cursor 값을 반환한다.") + void get_my_rooms_page_1() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity recruitingRoom2 = saveScienceRoom("모집중인방-책-2", "isbn2", "과학-방-2일뒤-활동시작", LocalDate.now().plusDays(2), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom2, 8); + + RoomJpaEntity recruitingRoom3 = saveScienceRoom("모집중인방-책-3", "isbn3", "과학-방-3일뒤-활동시작", LocalDate.now().plusDays(3), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom3, 8); + + RoomJpaEntity recruitingRoom4 = saveScienceRoom("모집중인방-책-4", "isbn4", "과학-방-4일뒤-활동시작", LocalDate.now().plusDays(4), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom4, 8); + + RoomJpaEntity recruitingRoom5 = saveScienceRoom("모집중인방-책-5", "isbn5", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom5, 8); + + RoomJpaEntity recruitingRoom6 = saveScienceRoom("모집중인방-책-6", "isbn6", "과학-방-6일뒤-활동시작", LocalDate.now().plusDays(6), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom6, 8); + + RoomJpaEntity recruitingRoom7 = saveScienceRoom("모집중인방-책-7", "isbn7", "과학-방-7일뒤-활동시작", LocalDate.now().plusDays(7), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom7, 8); + + RoomJpaEntity recruitingRoom8 = saveScienceRoom("모집중인방-책-8", "isbn8", "과학-방-8일뒤-활동시작", LocalDate.now().plusDays(8), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom8, 8); + + RoomJpaEntity recruitingRoom9 = saveScienceRoom("모집중인방-책-9", "isbn9", "과학-방-9일뒤-활동시작", LocalDate.now().plusDays(9), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom9, 8); + + RoomJpaEntity recruitingRoom10 = saveScienceRoom("모집중인방-책-10", "isbn10", "과학-방-10일뒤-활동시작", LocalDate.now().plusDays(10), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom10, 8); + + RoomJpaEntity recruitingRoom11 = saveScienceRoom("모집중인방-책-11", "isbn11", "과학-방-11일뒤-활동시작", LocalDate.now().plusDays(11), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom11, 8); + + RoomJpaEntity recruitingRoom12 = saveScienceRoom("모집중인방-책-12", "isbn12", "과학-방-12일뒤-활동시작", LocalDate.now().plusDays(12), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom12, 8); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(recruitingRoom2, user); + saveSingleUserToRoom(recruitingRoom3, user); + saveSingleUserToRoom(recruitingRoom4, user); + saveSingleUserToRoom(recruitingRoom5, user); + saveSingleUserToRoom(recruitingRoom6, user); + saveSingleUserToRoom(recruitingRoom7, user); + saveSingleUserToRoom(recruitingRoom8, user); + saveSingleUserToRoom(recruitingRoom9, user); + saveSingleUserToRoom(recruitingRoom10, user); + saveSingleUserToRoom(recruitingRoom11, user); + saveSingleUserToRoom(recruitingRoom12, user); + + //when + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "recruiting")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(false))) + .andExpect(jsonPath("$.data.roomList", hasSize(10))) + // 정렬 조건 : 모집중인 방 == 방 활동 시작일 임박 순 + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-2일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[2].roomName", is("과학-방-3일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[3].roomName", is("과학-방-4일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[4].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[5].roomName", is("과학-방-6일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[6].roomName", is("과학-방-7일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[7].roomName", is("과학-방-8일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[8].roomName", is("과학-방-9일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[9].roomName", is("과학-방-10일뒤-활동시작"))); + } + + @Test + @DisplayName("cursor 값을 기준으로 해당 페이지의 데이터를 반환한다.") + void get_my_rooms_page_2() throws Exception { + //given + RoomJpaEntity recruitingRoom1 = saveScienceRoom("모집중인방-책-1", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom1, 5); + + RoomJpaEntity recruitingRoom2 = saveScienceRoom("모집중인방-책-2", "isbn2", "과학-방-2일뒤-활동시작", LocalDate.now().plusDays(2), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom2, 8); + + RoomJpaEntity recruitingRoom3 = saveScienceRoom("모집중인방-책-3", "isbn3", "과학-방-3일뒤-활동시작", LocalDate.now().plusDays(3), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom3, 8); + + RoomJpaEntity recruitingRoom4 = saveScienceRoom("모집중인방-책-4", "isbn4", "과학-방-4일뒤-활동시작", LocalDate.now().plusDays(4), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom4, 8); + + RoomJpaEntity recruitingRoom5 = saveScienceRoom("모집중인방-책-5", "isbn5", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom5, 8); + + RoomJpaEntity recruitingRoom6 = saveScienceRoom("모집중인방-책-6", "isbn6", "과학-방-6일뒤-활동시작", LocalDate.now().plusDays(6), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom6, 8); + + RoomJpaEntity recruitingRoom7 = saveScienceRoom("모집중인방-책-7", "isbn7", "과학-방-7일뒤-활동시작", LocalDate.now().plusDays(7), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom7, 8); + + RoomJpaEntity recruitingRoom8 = saveScienceRoom("모집중인방-책-8", "isbn8", "과학-방-8일뒤-활동시작", LocalDate.now().plusDays(8), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom8, 8); + + RoomJpaEntity recruitingRoom9 = saveScienceRoom("모집중인방-책-9", "isbn9", "과학-방-9일뒤-활동시작", LocalDate.now().plusDays(9), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom9, 8); + + RoomJpaEntity recruitingRoom10 = saveScienceRoom("모집중인방-책-10", "isbn10", "과학-방-10일뒤-활동시작", LocalDate.now().plusDays(10), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom10, 8); + + RoomJpaEntity recruitingRoom11 = saveScienceRoom("모집중인방-책-11", "isbn11", "과학-방-11일뒤-활동시작", LocalDate.now().plusDays(11), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom11, 8); + + RoomJpaEntity recruitingRoom12 = saveScienceRoom("모집중인방-책-12", "isbn12", "과학-방-12일뒤-활동시작", LocalDate.now().plusDays(12), LocalDate.now().plusDays(30), 10); + changeRoomMemberCount(recruitingRoom12, 8); + + AliasJpaEntity scienceAlias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(scienceAlias)); + + // user가 생성한 방에 참여한 상황 가정 + saveSingleUserToRoom(recruitingRoom1, user); + saveSingleUserToRoom(recruitingRoom2, user); + saveSingleUserToRoom(recruitingRoom3, user); + saveSingleUserToRoom(recruitingRoom4, user); + saveSingleUserToRoom(recruitingRoom5, user); + saveSingleUserToRoom(recruitingRoom6, user); + saveSingleUserToRoom(recruitingRoom7, user); + saveSingleUserToRoom(recruitingRoom8, user); + saveSingleUserToRoom(recruitingRoom9, user); + saveSingleUserToRoom(recruitingRoom10, user); + saveSingleUserToRoom(recruitingRoom11, user); + saveSingleUserToRoom(recruitingRoom12, user); + + //when + String nextCursor = recruitingRoom10.getStartDate().toString() + "|" + recruitingRoom10.getRoomId().toString(); // 이전 페이지의 마지막 레코드인 room10이 nextCursor이다 + + ResultActions result = mockMvc.perform(get("/rooms/my") + .requestAttr("userId", user.getUserId()) + .param("type", "recruiting") + .param("cursor", nextCursor)); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isLast", is(true))) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-11일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("과학-방-12일뒤-활동시작"))); + } +}