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 48e28536a..7e85943c4 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 @@ -1,11 +1,15 @@ package konkuk.thip.room.adapter.in.web; import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; import konkuk.thip.room.application.port.in.RoomSearchUseCase; import jakarta.validation.Valid; import konkuk.thip.room.adapter.in.web.request.RoomVerifyPasswordRequest; import konkuk.thip.room.application.port.in.RoomVerifyPasswordUseCase; +import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -17,6 +21,8 @@ public class RoomQueryController { private final RoomSearchUseCase roomSearchUseCase; + private final RoomGetHomeJoinedListUseCase roomGetHomeJoinedListUseCase; + private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase; @GetMapping("/rooms/search") public BaseResponse searchRooms( @@ -27,7 +33,6 @@ public BaseResponse searchRooms( ) { return BaseResponse.ok(roomSearchUseCase.searchRoom(keyword, category, sort, page)); } - private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase; //비공개 방 비밀번호 입력 검증 @PostMapping("/rooms/{roomId}/password") @@ -37,4 +42,14 @@ public BaseResponse verifyRoomPassword(@PathVariable("roomId") final Long return BaseResponse.ok(roomVerifyPasswordUseCase.verifyRoomPassword(roomVerifyPasswordRequest.toQuery(roomId))); } + //[모임 홈] 참여중인 내 모임방 조회 + @GetMapping("/rooms/home/joined") + public BaseResponse getHomeJoinedRooms(@UserId final Long userId, + @RequestParam("page") final int page) { + return BaseResponse.ok(roomGetHomeJoinedListUseCase.getHomeJoinedRoomList( + RoomGetHomeJoinedListQuery.builder() + .userId(userId) + .page(page).build())); + } + } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java new file mode 100644 index 000000000..5c5e4e87e --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomGetHomeJoinedListResponse.java @@ -0,0 +1,26 @@ +package konkuk.thip.room.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record RoomGetHomeJoinedListResponse( + List roomList, + String nickname, + int page, // 현재 페이지 + int size, // 현재 페이지에 포함된 데이터 수 + boolean last, + boolean first +) { + + + @Builder + public record RoomSearchResult( + Long roomId, + String bookImageUrl, + String bookTitle, + int memberCount, + int userPercentage + ) {} +} 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 9deb47ef8..22e4ffd7a 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 @@ -7,6 +7,7 @@ import java.time.LocalDate; +//TODO 방에 이방에 참여중인 인원수 추가 @Entity @Table(name = "rooms") @Getter 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 e2e50f8c4..ba93f0189 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,5 +1,6 @@ package konkuk.thip.room.adapter.out.persistence; +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.application.port.out.RoomQueryPort; @@ -26,4 +27,9 @@ public int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate cu public Page searchRoom(String keyword, String category, Pageable pageable) { return roomJpaRepository.searchRoom(keyword, category, pageable); } + + @Override + public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { + return roomJpaRepository.searchHomeJoinedRooms(userId, date, pageable); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java index 56c000969..651e4cd0e 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java @@ -1,10 +1,14 @@ package konkuk.thip.room.adapter.out.persistence; +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.time.LocalDate; + public interface RoomQueryRepository { Page searchRoom(String keyword, String category, Pageable pageable); + Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java index f4b89a0a0..c6a5b7249 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java @@ -6,9 +6,12 @@ import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.dsl.CaseBuilder; 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.util.DateUtil; +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.user.adapter.out.jpa.QUserRoomJpaEntity; @@ -21,6 +24,7 @@ import java.time.LocalDate; import java.util.List; +import java.util.Optional; @Repository @RequiredArgsConstructor @@ -31,6 +35,7 @@ public class RoomQueryRepositoryImpl implements RoomQueryRepository { private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; private final QUserRoomJpaEntity userRoom = QUserRoomJpaEntity.userRoomJpaEntity; + @Override public Page searchRoom(String keyword, String category, Pageable pageable) { // 1. 검색 조건(where) 조립 : 방이름 or 첵제목에 keyword 포함, category 필터 적용, 멤버 모집중인(= 활동 시작전인) 방만 검색 @@ -137,4 +142,78 @@ private OrderSpecifier toOrderSpecifier(Sort sort, QRoomJpaEntity room, Numbe return room.startDate.asc(); } } + + @Override + public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { + + QUserRoomJpaEntity userRoomSub = new QUserRoomJpaEntity("userRoomSub"); + + // 1. 검색 조건(where) 조립 + // 유저가 참여한 방만: userId 조건 + // 활동 기간 중인 방만: startDate ≤ today ≤ endDate + BooleanBuilder where = new BooleanBuilder(); + where.and(userRoom.userJpaEntity.userId.eq(userId)); + where.and(room.startDate.loe(date)); + where.and(room.endDate.goe(date)); + + // TODO : Room 에 멤버 수 추가되면 로직 수정 + // 멤버 수 서브쿼리 + JPQLQuery memberCountSubQuery = JPAExpressions + .select(userRoomSub.count()) + .from(userRoomSub) + .where(userRoomSub.roomJpaEntity.roomId.eq(room.roomId)); + + // 2. 페이징된 목록 조회 + List tuples = queryFactory + .select( + room.roomId, + book.imageUrl, + room.title, + memberCountSubQuery, + room.recruitCount, + room.startDate, + book.title, + userRoom.userPercentage + ) + .from(userRoom) + .join(userRoom.roomJpaEntity, room) + .join(room.bookJpaEntity, book) + .where(where) + .orderBy( + userRoom.userPercentage.desc(), // 진행률 높은 순(내림차순) + room.startDate.asc() // 진행률 같으면 활동 시작일 빠른 순 (오름차순) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + // TODO : 추후에 오프셋 페이징이 아니라, 키셋 페이징 기법 도입 검토 + + // 3. Tuple → DTO 매핑 + List content = tuples.stream() + .map(t -> RoomGetHomeJoinedListResponse.RoomSearchResult.builder() + .roomId(t.get(room.roomId)) + .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)) + .map(val -> ((Number) val).doubleValue()) + .map(Math::round) + .map(Long::intValue) + .orElse(0)) + .build() + ) + .toList(); + + // 4. 전체 개수 조회 (페이징 정보 계산용) + Long totalCount = queryFactory + .select(userRoom.count()) + .from(userRoom) + .join(userRoom.roomJpaEntity, room) + .where(where) + .fetchOne(); + long total = (totalCount != null) ? totalCount : 0L; + + // 5. PageImpl 생성하여 반환 + return new PageImpl<>(content, pageable, total); + } } diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomGetHomeJoinedListUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomGetHomeJoinedListUseCase.java new file mode 100644 index 000000000..f37e79d7b --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomGetHomeJoinedListUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; +import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; + +public interface RoomGetHomeJoinedListUseCase { + RoomGetHomeJoinedListResponse getHomeJoinedRoomList(RoomGetHomeJoinedListQuery roomGetHomeJoinedListQuery); +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java new file mode 100644 index 000000000..77cb3263f --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomGetHomeJoinedListQuery.java @@ -0,0 +1,10 @@ +package konkuk.thip.room.application.port.in.dto; + +import lombok.Builder; + +@Builder +public record RoomGetHomeJoinedListQuery( + Long userId, + int page +) { +} 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 1cd001ab1..8d55f98a7 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,5 +1,6 @@ package konkuk.thip.room.application.port.out; +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -8,6 +9,6 @@ public interface RoomQueryPort { int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate); - Page searchRoom(String keyword, String category, Pageable pageable); + Page searchHomeJoinedRooms(Long userId, LocalDate today, Pageable pageable); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java b/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java new file mode 100644 index 000000000..b31b41b9b --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomGetHomeJoinedListService.java @@ -0,0 +1,62 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.room.adapter.in.web.response.RoomGetHomeJoinedListResponse; +import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; +import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; +import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +@Service +@RequiredArgsConstructor +public class RoomGetHomeJoinedListService implements RoomGetHomeJoinedListUseCase { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final RoomQueryPort roomQueryPort; + private final UserCommandPort userCommandPort; + + @Override + @Transactional(readOnly = true) + public RoomGetHomeJoinedListResponse getHomeJoinedRoomList(RoomGetHomeJoinedListQuery query) { + + // 1. page 값 검증 + validatePage(query.page()); + + // 2. 유저 닉네임 조회 + String nickname = userCommandPort.findById(query.userId()).getNickname(); + + // 3. Pageable 생성 + int pageIndex = query.page() > 0 ? query.page() - 1 : 0; + Pageable pageable = PageRequest.of(pageIndex, DEFAULT_PAGE_SIZE); + + // 4. 모임 홈에서 참여중인 모임 방 검색 + Page result = roomQueryPort.searchHomeJoinedRooms(query.userId(), LocalDate.now(), pageable); + + // 5. response 구성 + return RoomGetHomeJoinedListResponse.builder() + .roomList(result.getContent()) + .nickname(nickname) + .page(query.page()) + .size(result.getNumberOfElements()) + .last(result.isLast()) + .first(result.isFirst()) + .build(); + } + + private void validatePage(int page) { + if(page< 1) { + throw new InvalidStateException(ErrorCode.API_INVALID_PARAM, new IllegalArgumentException("page은 1 이상의 값이어야 합니다.")); + } + } + +} diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index 2df450014..ffd760b10 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -4,9 +4,7 @@ import konkuk.thip.record.adapter.out.jpa.RecordJpaEntity; import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; -import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; -import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.jpa.*; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; @@ -85,6 +83,29 @@ public static RoomJpaEntity createRoom(BookJpaEntity book, CategoryJpaEntity cat .build(); } + public static RoomJpaEntity createCustomRoom(BookJpaEntity book, CategoryJpaEntity category,LocalDate startDate,LocalDate endDate) { + return RoomJpaEntity.builder() + .title("방이름") + .description("설명") + .isPublic(true) + .startDate(startDate) + .endDate(endDate) + .recruitCount(3) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build(); + } + + public static UserRoomJpaEntity createUserRoom(RoomJpaEntity room, UserJpaEntity user, UserRoomRole userRoomRole, double userPercentage) { + return UserRoomJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(room) + .userRoomRole(userRoomRole) + .currentPage(0) + .userPercentage(userPercentage) + .build(); + } + public static RecordJpaEntity createRecord(UserJpaEntity user, RoomJpaEntity room) { return RecordJpaEntity.builder() .content("기록 내용") diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java new file mode 100644 index 000000000..388321d4a --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsApiTest.java @@ -0,0 +1,235 @@ +package konkuk.thip.room.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.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.persistence.CategoryJpaRepository; +import konkuk.thip.room.adapter.out.persistence.RoomJpaRepository; +import konkuk.thip.user.adapter.out.jpa.*; +import konkuk.thip.user.adapter.out.persistence.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.UserRoomJpaRepository; +import org.junit.jupiter.api.AfterEach; +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 java.time.LocalDate; + +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.is; +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 통합 테스트") +class RoomGetHomeJoinedRoomsApiTest { + + @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 UserRoomJpaRepository userRoomJpaRepository; + + private RoomJpaEntity room1; + private RoomJpaEntity room2; + private UserJpaEntity user1; + private UserJpaEntity user2; + private BookJpaEntity book; + private CategoryJpaEntity category; + + @BeforeEach + void setUp() { + + AliasJpaEntity alias = TestEntityFactory.createLiteratureAlias(); + aliasJpaRepository.save(alias); + + user1 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + user2 = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + book = TestEntityFactory.createBook(); + bookJpaRepository.save(book); + + category = TestEntityFactory.createLiteratureCategory(alias); + categoryJpaRepository.save(category); + + room1 = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + room2 = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + // 1번방에 유저 1이 호스트, 유저2가 멤버 + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room1,user1,UserRoomRole.HOST, 80.0)); + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room1,user2,UserRoomRole.MEMBER, 60.0)); + + // 2번방에 유저 1이 호스트 + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room2,user1,UserRoomRole.HOST,60.0)); + } + + @AfterEach + void tearDown() { + userRoomJpaRepository.deleteAll(); + roomJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + @Test + @DisplayName("사용자가 참여중인 방 목록이 현재 진행되고 있는 방 중에서 진행률 내림차순, 시작일 오름차순으로 조회된다.") + void getHomeJoinedRooms_success() throws Exception { + + //given + Long userId = user1.getUserId(); + + //when + ResultActions result = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", userId) + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.nickname").exists()) + .andExpect(jsonPath("$.data.page", is(1))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + // 진행률 내림차순, 시작일 오름차순 정렬 검증 + .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(80))) + .andExpect(jsonPath("$.data.roomList[1].userPercentage", is(60))) + .andExpect(jsonPath("$.data.roomList[0].roomId").exists()) + .andExpect(jsonPath("$.data.roomList[1].roomId").exists()); + } + + @Test + @DisplayName("사용자가 참여중인 방 목록 중 현재 진행되고 있는 방 중에서 진행률이 같을 때 시작일이 빠른 방이 먼저 조회된다.") + void getHomeJoinedRooms_sortByStartDateWhenUserPercentageEquals() throws Exception { + + // given + AliasJpaEntity alias = TestEntityFactory.createLiteratureAlias(); + aliasJpaRepository.save(alias); + + UserJpaEntity newUser = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // 방1: 시작일 오늘-2 + RoomJpaEntity room1 = roomJpaRepository.save( + TestEntityFactory.createCustomRoom(book, category, LocalDate.now().minusDays(2), LocalDate.now().plusDays(10)) + ); + // 방2: 시작일 오늘-1 + RoomJpaEntity room2 = roomJpaRepository.save( + TestEntityFactory.createCustomRoom(book, category, LocalDate.now().minusDays(1), LocalDate.now().plusDays(8)) + ); + // 방3: 시작일 오늘 + RoomJpaEntity room3 = roomJpaRepository.save( + TestEntityFactory.createCustomRoom(book, category, LocalDate.now(), LocalDate.now().plusDays(9)) + ); + + // 모두 동일한 진행률(70%)로 참여 + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room1, newUser, UserRoomRole.MEMBER, 70.0)); + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room2, newUser, UserRoomRole.MEMBER, 70.0)); + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(room3, newUser, UserRoomRole.MEMBER, 70.0)); + + Long userId = newUser.getUserId(); + + // when + ResultActions result = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", userId) + .param("page", "1")); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(3))) + // 모두 진행률 70% + .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(70))) + .andExpect(jsonPath("$.data.roomList[1].userPercentage", is(70))) + .andExpect(jsonPath("$.data.roomList[2].userPercentage", is(70))) + // 시작일 빠른 순서로 정렬되었는지 검증 (room1 → room2 → room3) + .andExpect(jsonPath("$.data.roomList[0].roomId", is(room1.getRoomId().intValue()))) + .andExpect(jsonPath("$.data.roomList[1].roomId", is(room2.getRoomId().intValue()))) + .andExpect(jsonPath("$.data.roomList[2].roomId", is(room3.getRoomId().intValue()))); + } + + @Test + @DisplayName("사용자가 참여중인 방 목록 중 모집중(시작 전)인 방은 참여중 목록에 포함되지 않는다.") + void getHomeJoinedRooms_excludeRecruitingRooms() throws Exception { + + // given + AliasJpaEntity alias = TestEntityFactory.createLiteratureAlias(); + aliasJpaRepository.save(alias); + + UserJpaEntity newUser = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + // 모집중(시작일 미래) + RoomJpaEntity recruitRoom = roomJpaRepository.save( + TestEntityFactory.createCustomRoom(book, category, LocalDate.now().plusDays(2), LocalDate.now().plusDays(5)) + ); + // 활동중(시작일 오늘-1, 종료일 오늘+2) + RoomJpaEntity activeRoom = roomJpaRepository.save( + TestEntityFactory.createCustomRoom(book, category, LocalDate.now().minusDays(1), LocalDate.now().plusDays(2)) + ); + + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(recruitRoom, newUser, UserRoomRole.MEMBER, 20.0)); + userRoomJpaRepository.save(TestEntityFactory.createUserRoom(activeRoom, newUser, UserRoomRole.MEMBER, 50.0)); + + + // when + ResultActions result = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", newUser.getUserId()) + .param("page", "1")); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(1))) + .andExpect(jsonPath("$.data.roomList[0].roomId", is(activeRoom.getRoomId().intValue()))) + .andExpect(jsonPath("$.data.roomList[0].userPercentage", is(50))); + } + + + @Test + @DisplayName("사용자가 참여중인 방이 없으면 빈 리스트를 반환한다.") + void getHomeJoinedRooms_empty() throws Exception { + + //given + AliasJpaEntity alias = TestEntityFactory.createLiteratureAlias(); + aliasJpaRepository.save(alias); + UserJpaEntity newUser = userJpaRepository.save(TestEntityFactory.createUser(alias)); + + //when + ResultActions result = mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", newUser.getUserId()) + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(0))) + .andExpect(jsonPath("$.data.page", is(1))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))); + } + +} diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java new file mode 100644 index 000000000..1f14f50c9 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomGetHomeJoinedRoomsControllerTest.java @@ -0,0 +1,71 @@ +package konkuk.thip.room.adapter.in.web; + +import org.junit.jupiter.api.*; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; +import static org.hamcrest.Matchers.*; +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 controller 테스트") +class RoomGetHomeJoinedRoomsControllerTest { + + @Autowired + private MockMvc mockMvc; + + + private Map buildValidRequest() { + Map request = new HashMap<>(); + request.put("userId", 1L); + request.put("page", 1); + return request; + } + + private void assertBad(Map req, String msg) throws Exception { + mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", req.get("userId")) + .param("page", req.get("page") != null ? req.get("page").toString() : null) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString(msg))); + } + + @Nested + @DisplayName("page 파라미터 검증") + class PageValidation { + + @Test + @DisplayName("page가 1 미만일 때 400 error") + void invalid_page() throws Exception { + Map req = buildValidRequest(); + req.put("page", 0); + assertBad(req, "page은 1 이상의 값이어야 합니다."); + } + + @Test + @DisplayName("page가 null일 때 400 error") + void missing_page() throws Exception { + Map req = buildValidRequest(); + req.remove("page"); + mockMvc.perform(get("/rooms/home/joined") + .requestAttr("userId", req.get("userId")) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isBadRequest()); + } + } + +} \ No newline at end of file