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 52b8953a3..8a0aa6adf 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -98,6 +98,7 @@ public enum ErrorCode implements ResponseCode { * 140000 : userRoom error */ USER_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 140000, "존재하지 않는 USER_ROOM (방과 사용자 관계) 입니다."), + USER_NOT_BELONG_TO_ROOM(HttpStatus.BAD_REQUEST, 140001, "현재 모임방에 속하지 않는 유저입니다."), /** * 150000 : Category error 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 6ffa7db3b..d185ed994 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,17 +2,17 @@ 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.application.port.in.*; import konkuk.thip.room.application.port.in.RoomGetHomeJoinedListUseCase; import konkuk.thip.room.application.port.in.RoomGetMemberListUseCase; 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.RoomShowRecruitingDetailViewUseCase; -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; @@ -29,6 +29,7 @@ public class RoomQueryController { private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase; private final RoomShowRecruitingDetailViewUseCase roomShowRecruitingDetailViewUseCase; private final RoomGetMemberListUseCase roomGetMemberListUseCase; + private final RoomShowPlayingDetailViewUseCase roomShowPlayingDetailViewUseCase; @GetMapping("/rooms/search") public BaseResponse searchRooms( @@ -48,6 +49,7 @@ public BaseResponse verifyRoomPassword(@PathVariable("roomId") final Long return BaseResponse.ok(roomVerifyPasswordUseCase.verifyRoomPassword(roomVerifyPasswordRequest.toQuery(roomId))); } + // 모집중인 방 상세보기 @GetMapping("/rooms/{roomId}/recruiting") public BaseResponse getRecruitingRoomDetailView( @UserId final Long userId, @@ -71,4 +73,13 @@ public BaseResponse getRoomMemberList(@PathVariable(" return BaseResponse.ok(roomGetMemberListUseCase.getRoomMemberList(roomId)); } + // 진행중인 방 상세보기 + @GetMapping("/rooms/{roomId}/playing") + public BaseResponse getPlayingRoomDetailView( + @UserId final Long userId, + @PathVariable("roomId") final Long roomId + ) { + return BaseResponse.ok(roomShowPlayingDetailViewUseCase.getPlayingRoomDetailView(userId, roomId)); + } + } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPlayingDetailViewResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPlayingDetailViewResponse.java new file mode 100644 index 000000000..e38152d34 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomPlayingDetailViewResponse.java @@ -0,0 +1,37 @@ +package konkuk.thip.room.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record RoomPlayingDetailViewResponse( + boolean isHost, + Long roomId, + String roomName, + String roomImageUrl, + boolean isPublic, + String progressStartDate, + String progressEndDate, + String category, + String roomDescription, + int memberCount, + int recruitCount, + String isbn, + String bookTitle, + String authorName, + int currentPage, + double userPercentage, + List currentVotes +) { + public record CurrentVote( + String content, + int page, + boolean isOverview, + List voteItems + ) { + public record VoteItem( + String itemName + ) {} + } +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomShowPlayingDetailViewUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomShowPlayingDetailViewUseCase.java new file mode 100644 index 000000000..abc8f2624 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomShowPlayingDetailViewUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; + +public interface RoomShowPlayingDetailViewUseCase { + + RoomPlayingDetailViewResponse getPlayingRoomDetailView(Long userId, Long roomId); +} diff --git a/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingDetailViewService.java b/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingDetailViewService.java new file mode 100644 index 000000000..05318680d --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingDetailViewService.java @@ -0,0 +1,71 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.util.DateUtil; +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; +import konkuk.thip.room.application.port.in.RoomShowPlayingDetailViewUseCase; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.domain.Room; +import konkuk.thip.user.application.port.out.UserRoomCommandPort; +import konkuk.thip.user.domain.RoomParticipants; +import konkuk.thip.user.domain.UserRoom; +import konkuk.thip.vote.application.port.out.VoteQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RoomShowPlayingDetailViewService implements RoomShowPlayingDetailViewUseCase { + + private static final int TOP_PARTICIPATION_VOTES_COUNT = 3; + + private final RoomCommandPort roomCommandPort; + private final BookCommandPort bookCommandPort; + private final UserRoomCommandPort userRoomCommandPort; + private final VoteQueryPort voteQueryPort; + + @Override + @Transactional(readOnly = true) + public RoomPlayingDetailViewResponse getPlayingRoomDetailView(Long userId, Long roomId) { + // 1. Room 조회, Book 조회 + Room room = roomCommandPort.findById(roomId); + Book book = bookCommandPort.findById(room.getBookId()); + + // 2. Room과 연관된 UserRoom 조회, RoomParticipants 일급 컬렉션 생성 + // TODO. Room 도메인에 memberCount 값 추가된 후 리펙토링 + List findByRoomId = userRoomCommandPort.findAllByRoomId(roomId); + RoomParticipants roomParticipants = RoomParticipants.from(findByRoomId); + + // 3. 투표 참여율이 가장 높은 투표 조회 + List topParticipationVotes = voteQueryPort.findTopParticipationVotesByRoom(room, TOP_PARTICIPATION_VOTES_COUNT); + + // 4. response 구성 + return buildResponse(userId, room, book, roomParticipants, topParticipationVotes); + } + + private RoomPlayingDetailViewResponse buildResponse(Long userId, Room room, Book book, RoomParticipants roomParticipants, List topParticipationVotes) { + return RoomPlayingDetailViewResponse.builder() + .isHost(roomParticipants.isHostOfRoom(userId)) + .roomId(room.getId()) + .roomName(room.getTitle()) + .roomImageUrl(room.getCategory().getImageUrl()) + .isPublic(room.isPublic()) + .progressStartDate(DateUtil.formatDate(room.getStartDate())) + .progressEndDate(DateUtil.formatDate(room.getEndDate())) + .category(room.getCategory().getValue()) + .roomDescription(room.getDescription()) + .memberCount(roomParticipants.calculateMemberCount()) + .recruitCount(room.getRecruitCount()) + .isbn(book.getIsbn()) + .bookTitle(book.getTitle()) + .authorName(book.getAuthorName()) + .currentPage(roomParticipants.getCurrentPageOfUser(userId)) + .userPercentage(roomParticipants.getUserPercentageOfUser(userId)) + .currentVotes(topParticipationVotes) + .build(); + } +} diff --git a/src/main/java/konkuk/thip/user/domain/RoomParticipants.java b/src/main/java/konkuk/thip/user/domain/RoomParticipants.java index bceacbf17..5249b1b21 100644 --- a/src/main/java/konkuk/thip/user/domain/RoomParticipants.java +++ b/src/main/java/konkuk/thip/user/domain/RoomParticipants.java @@ -1,11 +1,14 @@ package konkuk.thip.user.domain; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.user.adapter.out.jpa.UserRoomRole; import lombok.Getter; import lombok.RequiredArgsConstructor; import java.util.List; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_BELONG_TO_ROOM; + @Getter @RequiredArgsConstructor public class RoomParticipants { @@ -33,4 +36,20 @@ public boolean isHostOfRoom(Long userId) { .filter(userRoom -> userRoom.getUserId().equals(userId)) .anyMatch(userRoom -> userRoom.getUserRoomRole().equals(UserRoomRole.HOST.getType())); } + + public int getCurrentPageOfUser(Long userId) { + return participants.stream() + .filter(userRoom -> userRoom.getUserId().equals(userId)) + .map(UserRoom::getCurrentPage) + .findFirst() + .orElseThrow(() -> new InvalidStateException(USER_NOT_BELONG_TO_ROOM)); + } + + public double getUserPercentageOfUser(Long userId) { + return participants.stream() + .filter(userRoom -> userRoom.getUserId().equals(userId)) + .map(UserRoom::getUserPercentage) + .findFirst() + .orElseThrow(() -> new InvalidStateException(USER_NOT_BELONG_TO_ROOM)); + } } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java index e14eebee4..cc29dbb87 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryPersistenceAdapter.java @@ -1,10 +1,14 @@ package konkuk.thip.vote.adapter.out.persistence; +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; +import konkuk.thip.room.domain.Room; import konkuk.thip.vote.adapter.out.mapper.VoteMapper; import konkuk.thip.vote.application.port.out.VoteQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository @RequiredArgsConstructor public class VoteQueryPersistenceAdapter implements VoteQueryPort { @@ -17,4 +21,9 @@ public class VoteQueryPersistenceAdapter implements VoteQueryPort { public boolean isUserVoted(Long userId, Long voteItemId) { return userVoteJpaRepository.existsByUserJpaEntity_UserIdAndVoteItemJpaEntity_VoteItemId(userId, voteItemId); } + + @Override + public List findTopParticipationVotesByRoom(Room room, int count) { + return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count); + } } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepository.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepository.java index 92c4463cb..736aea3f5 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepository.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepository.java @@ -1,5 +1,6 @@ package konkuk.thip.vote.adapter.out.persistence; +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import java.util.List; @@ -7,4 +8,6 @@ public interface VoteQueryRepository { List findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId); + + List findTopParticipationVotesByRoom(Long roomId, int count); } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java index 27543256c..98f5b96ef 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteQueryRepositoryImpl.java @@ -2,7 +2,9 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.QVoteItemJpaEntity; import konkuk.thip.vote.adapter.out.jpa.QVoteJpaEntity; import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; import lombok.RequiredArgsConstructor; @@ -16,11 +18,12 @@ public class VoteQueryRepositoryImpl implements VoteQueryRepository { private final JPAQueryFactory jpaQueryFactory; + private final QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity; + private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; + private final QVoteItemJpaEntity voteItem = QVoteItemJpaEntity.voteItemJpaEntity; + @Override public List findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId) { - QVoteJpaEntity vote = QVoteJpaEntity.voteJpaEntity; - QUserJpaEntity user = QUserJpaEntity.userJpaEntity; - return jpaQueryFactory .select(vote) .from(vote) @@ -43,4 +46,39 @@ private BooleanExpression filterByType(String type, QVoteJpaEntity post, Long us } return null; } + + @Override + public List findTopParticipationVotesByRoom(Long roomId, int count) { + // 1. Fetch top votes by total participation count + List topVotes = jpaQueryFactory + .select(vote) + .from(vote) + .leftJoin(voteItem).on(voteItem.voteJpaEntity.eq(vote)) + .where(vote.roomJpaEntity.roomId.eq(roomId)) + .groupBy(vote) + .orderBy(voteItem.count.sum().desc()) // 해당 투표에 참여한 총 참여자 수 기준 내림차순 정렬 + .limit(count) + .fetch(); + + // 2. Map to DTOs including vote items + return topVotes.stream() + .map(vote -> { + List voteItems = jpaQueryFactory + .select(voteItem) + .from(voteItem) + .where(voteItem.voteJpaEntity.eq(vote)) + .orderBy(voteItem.count.desc()) + .fetch() + .stream() + .map(item -> new RoomPlayingDetailViewResponse.CurrentVote.VoteItem(item.getItemName())) + .toList(); + return new RoomPlayingDetailViewResponse.CurrentVote( + vote.getContent(), + vote.getPage(), + vote.isOverview(), + voteItems + ); + }) + .toList(); + } } diff --git a/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java b/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java index e2655502e..73b820288 100644 --- a/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java +++ b/src/main/java/konkuk/thip/vote/application/port/out/VoteQueryPort.java @@ -1,7 +1,13 @@ package konkuk.thip.vote.application.port.out; +import konkuk.thip.room.adapter.in.web.response.RoomPlayingDetailViewResponse; +import konkuk.thip.room.domain.Room; + +import java.util.List; + public interface VoteQueryPort { boolean isUserVoted(Long userId, Long voteId); + List findTopParticipationVotesByRoom(Room room, int count); } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java new file mode 100644 index 000000000..9f8128629 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomPlayingDetailViewApiTest.java @@ -0,0 +1,379 @@ +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.exception.InvalidStateException; +import konkuk.thip.common.util.DateUtil; +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 konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.adapter.out.persistence.VoteItemJpaRepository; +import konkuk.thip.vote.adapter.out.persistence.VoteJpaRepository; +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 java.util.List; +import java.util.stream.IntStream; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_BELONG_TO_ROOM; +import static org.hamcrest.Matchers.*; +import static org.junit.jupiter.api.Assertions.assertThrows; +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 RoomPlayingDetailViewApiTest { + + @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; + + @Autowired + private VoteJpaRepository voteJpaRepository; + + @Autowired + private VoteItemJpaRepository voteItemJpaRepository; + + @AfterEach + void tearDown() { + voteItemJpaRepository.deleteAll(); + voteJpaRepository.deleteAll(); + userRoomJpaRepository.deleteAll(); + roomJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + private RoomJpaEntity saveScienceRoom(String bookTitle, String isbn, String roomName, LocalDate startDate, 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(LocalDate.now().plusDays(30)) + .recruitCount(recruitCount) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build()); + } + + private void saveUsersToRoom(RoomJpaEntity roomJpaEntity, int count) { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createScienceAlias()); + + // User 리스트 생성 및 저장 + List users = IntStream.rangeClosed(1, count) + .mapToObj(i -> UserJpaEntity.builder() + .nickname("user" + i) + .imageUrl("http://image") + .oauth2Id("oauth2Id") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()) + .toList(); + + List savedUsers = userJpaRepository.saveAll(users); + + // UserRoom 매핑 리스트 생성 및 저장 + List mappings = savedUsers.stream() + .map(user -> UserRoomJpaEntity.builder() + .userJpaEntity(user) + .roomJpaEntity(roomJpaEntity) + .userRoomRole(UserRoomRole.MEMBER) + .build()) + .toList(); + + userRoomJpaRepository.saveAll(mappings); + } + + private void createVoteToRoom(UserJpaEntity creator, RoomJpaEntity roomJpaEntity, int count) { + for (int v = 1; v <= count; v++) { + VoteJpaEntity voteJpaEntity = voteJpaRepository.save( + VoteJpaEntity.builder() + .content("vote-content-" + v) + .likeCount(0) + .commentCount(0) + .userJpaEntity(creator) + .page(v * 10) + .isOverview(false) + .roomJpaEntity(roomJpaEntity) + .build() + ); + + for (int vi = 1; vi <= 2; vi++) { + voteItemJpaRepository.save( + VoteItemJpaEntity.builder() + .itemName("item-" + v + "-" + vi) + .count(v * 10) // v값이 클수록 해당 투표의 투표항목을 선택한 사람 수가 많다 == 해당 투표의 참여율이 높다 + .voteJpaEntity(voteJpaEntity) + .build() + ); + } + } + } + + @Test + @DisplayName("진행중인 모임방 상세조회할 경우, [해당 모임방의 정보, 책 정보, 유저의 현재 활동 정보, 현재 진행중인 투표]를 반환한다.") + void get_playing_room_detail() throws Exception { + //given + RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(room, 4); + UserRoomJpaEntity userRoomJpaEntity = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + userRoomJpaRepository.delete(userRoomJpaEntity); + UserRoomJpaEntity joiningMember = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(userRoomJpaEntity.getUserJpaEntity()) + .roomJpaEntity(userRoomJpaEntity.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.MEMBER) // Member + .currentPage(50) // 현재 member의 마지막 활동 page + .userPercentage(10.6) // 현재 member의 활동 percentage + .build()); + + createVoteToRoom(joiningMember.getUserJpaEntity(), room, 2); // 2개의 투표 생성 + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/playing", room.getRoomId()) + .requestAttr("userId", joiningMember.getUserJpaEntity().getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomImageUrl", is("과학/IT_image"))) // 방 대표 이미지 추가 + .andExpect(jsonPath("$.data.progressStartDate", is(DateUtil.formatDate(LocalDate.now().plusDays(1))))) + .andExpect(jsonPath("$.data.memberCount", is(4))) + .andExpect(jsonPath("$.data.recruitCount", is(10))) + .andExpect(jsonPath("$.data.isbn", is("isbn1"))) + .andExpect(jsonPath("$.data.bookTitle", is("과학-책"))) + .andExpect(jsonPath("$.data.currentPage", is(50))) + .andExpect(jsonPath("$.data.userPercentage", is(10.6))) + .andExpect(jsonPath("$.data.currentVotes", hasSize(2))) + /** + * currentVotes 검증 : 현재 모임방의 참여율이 높은 투표와 투표 항목들을 노출 + * <정렬 순서> : 투표 참여율 높은 순 (vote 2 -> vote 1 순) + */ + .andExpect(jsonPath("$.data.currentVotes[0].content", is("vote-content-2"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[0].itemName", is("item-2-1"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[1].itemName", is("item-2-2"))) + + .andExpect(jsonPath("$.data.currentVotes[1].content", is("vote-content-1"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[0].itemName", is("item-1-1"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[1].itemName", is("item-1-2"))); + } + + @Test + @DisplayName("모임방의 호스트가 조회할 경우, 유저가 해당 방의 호스트임을 응답값으로 보여준다. (나머지 응답값은 동일)") + void get_playing_room_detail_host() throws Exception { + //given + RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(room, 4); + UserRoomJpaEntity userRoomJpaEntity = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + userRoomJpaRepository.delete(userRoomJpaEntity); + UserRoomJpaEntity roomHost = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(userRoomJpaEntity.getUserJpaEntity()) + .roomJpaEntity(userRoomJpaEntity.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.HOST) // HOST + .currentPage(50) // 현재 member의 마지막 활동 page + .userPercentage(10.6) // 현재 member의 활동 percentage + .build()); + + createVoteToRoom(roomHost.getUserJpaEntity(), room, 2); // 2개의 투표 생성 + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/playing", room.getRoomId()) + .requestAttr("userId", roomHost.getUserJpaEntity().getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(true))) // 방 HOST 이면 true + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomImageUrl", is("과학/IT_image"))) // 방 대표 이미지 추가 + .andExpect(jsonPath("$.data.progressStartDate", is(DateUtil.formatDate(LocalDate.now().plusDays(1))))) + .andExpect(jsonPath("$.data.memberCount", is(4))) + .andExpect(jsonPath("$.data.recruitCount", is(10))) + .andExpect(jsonPath("$.data.isbn", is("isbn1"))) + .andExpect(jsonPath("$.data.bookTitle", is("과학-책"))) + .andExpect(jsonPath("$.data.currentPage", is(50))) + .andExpect(jsonPath("$.data.userPercentage", is(10.6))) + .andExpect(jsonPath("$.data.currentVotes", hasSize(2))) + /** + * currentVotes 검증 : 현재 모임방의 참여율이 높은 투표와 투표 항목들을 노출 + * <정렬 순서> : 투표 참여율 높은 순 (vote 2 -> vote 1 순) + */ + .andExpect(jsonPath("$.data.currentVotes[0].content", is("vote-content-2"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[0].itemName", is("item-2-1"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[1].itemName", is("item-2-2"))) + + .andExpect(jsonPath("$.data.currentVotes[1].content", is("vote-content-1"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[0].itemName", is("item-1-1"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[1].itemName", is("item-1-2"))); + } + + @Test + @DisplayName("모임방에 속하지 않는 유저가 진행중인 모임방 상세조회를 요청한 경우, 400 error 발생한다.") + void get_playing_room_detail_not_belong_to_room() throws Exception { + //given + RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(room, 4); + UserRoomJpaEntity userRoomJpaEntity = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + userRoomJpaRepository.delete(userRoomJpaEntity); + UserRoomJpaEntity joiningMember = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(userRoomJpaEntity.getUserJpaEntity()) + .roomJpaEntity(userRoomJpaEntity.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.MEMBER) // Member + .currentPage(50) // 현재 member의 마지막 활동 page + .userPercentage(10.6) // 현재 member의 활동 percentage + .build()); + + createVoteToRoom(joiningMember.getUserJpaEntity(), room, 2); // 2개의 투표 생성 + + //when //then + mockMvc.perform(get("/rooms/{roomId}/playing", room.getRoomId()) + .requestAttr("userId", 1000L)) // 방에 속하지 않는 유저 + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(USER_NOT_BELONG_TO_ROOM.getCode())) + .andExpect(jsonPath("$.message", containsString(USER_NOT_BELONG_TO_ROOM.getMessage()))); + } + + @Test + @DisplayName("모임방에서 진행중인 투표가 많을 경우, 참여율이 높은 순으로 최대 3개의 투표만 보여준다.") + void get_playing_room_detail_too_many_votes() throws Exception { + //given + RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(room, 4); + UserRoomJpaEntity userRoomJpaEntity = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + userRoomJpaRepository.delete(userRoomJpaEntity); + UserRoomJpaEntity joiningMember = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(userRoomJpaEntity.getUserJpaEntity()) + .roomJpaEntity(userRoomJpaEntity.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.MEMBER) // Member + .currentPage(50) // 현재 member의 마지막 활동 page + .userPercentage(10.6) // 현재 member의 활동 percentage + .build()); + + createVoteToRoom(joiningMember.getUserJpaEntity(), room, 6); // 6개의 투표 생성 + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/playing", room.getRoomId()) + .requestAttr("userId", joiningMember.getUserJpaEntity().getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomImageUrl", is("과학/IT_image"))) // 방 대표 이미지 추가 + .andExpect(jsonPath("$.data.progressStartDate", is(DateUtil.formatDate(LocalDate.now().plusDays(1))))) + .andExpect(jsonPath("$.data.memberCount", is(4))) + .andExpect(jsonPath("$.data.recruitCount", is(10))) + .andExpect(jsonPath("$.data.isbn", is("isbn1"))) + .andExpect(jsonPath("$.data.bookTitle", is("과학-책"))) + .andExpect(jsonPath("$.data.currentPage", is(50))) + .andExpect(jsonPath("$.data.userPercentage", is(10.6))) + .andExpect(jsonPath("$.data.currentVotes", hasSize(3))) + /** + * currentVotes 검증 : 현재 모임방의 참여율이 높은 투표와 투표 항목들을 노출 + * <정렬 순서> : 투표 참여율 높은 순 (vote 6 -> vote 5 -> vote 4 순) + */ + .andExpect(jsonPath("$.data.currentVotes[0].content", is("vote-content-6"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[0].itemName", is("item-6-1"))) + .andExpect(jsonPath("$.data.currentVotes[0].voteItems[1].itemName", is("item-6-2"))) + + .andExpect(jsonPath("$.data.currentVotes[1].content", is("vote-content-5"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[0].itemName", is("item-5-1"))) + .andExpect(jsonPath("$.data.currentVotes[1].voteItems[1].itemName", is("item-5-2"))) + + .andExpect(jsonPath("$.data.currentVotes[2].content", is("vote-content-4"))) + .andExpect(jsonPath("$.data.currentVotes[2].voteItems[0].itemName", is("item-4-1"))) + .andExpect(jsonPath("$.data.currentVotes[2].voteItems[1].itemName", is("item-4-2"))); + } + + @Test + @DisplayName("모임방에서 진행중인 투표가 없을 경우, 빈 리스트를 보여준다.") + void get_playing_room_detail_no_votes() throws Exception { + //given + RoomJpaEntity room = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(room, 4); + UserRoomJpaEntity userRoomJpaEntity = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(room.getRoomId()).get(0); + userRoomJpaRepository.delete(userRoomJpaEntity); + UserRoomJpaEntity joiningMember = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(userRoomJpaEntity.getUserJpaEntity()) + .roomJpaEntity(userRoomJpaEntity.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.MEMBER) // Member + .currentPage(50) // 현재 member의 마지막 활동 page + .userPercentage(10.6) // 현재 member의 활동 percentage + .build()); + + createVoteToRoom(joiningMember.getUserJpaEntity(), room, 0); // 투표 생성 X + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/playing", room.getRoomId()) + .requestAttr("userId", joiningMember.getUserJpaEntity().getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomImageUrl", is("과학/IT_image"))) // 방 대표 이미지 추가 + .andExpect(jsonPath("$.data.progressStartDate", is(DateUtil.formatDate(LocalDate.now().plusDays(1))))) + .andExpect(jsonPath("$.data.memberCount", is(4))) + .andExpect(jsonPath("$.data.recruitCount", is(10))) + .andExpect(jsonPath("$.data.isbn", is("isbn1"))) + .andExpect(jsonPath("$.data.bookTitle", is("과학-책"))) + .andExpect(jsonPath("$.data.currentPage", is(50))) + .andExpect(jsonPath("$.data.userPercentage", is(10.6))) + .andExpect(jsonPath("$.data.currentVotes", hasSize(0))); // 투표 없음 + } +} diff --git a/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java b/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java index 88fc566ed..8b5fbd89a 100644 --- a/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java +++ b/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java @@ -1,5 +1,6 @@ package konkuk.thip.user.domain; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.user.adapter.out.jpa.UserRoomRole; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -61,4 +62,54 @@ void is_host_of_room_test() { assertFalse(participants.isHostOfRoom(1L)); assertFalse(participants.isHostOfRoom(3L)); } + + @Test + @DisplayName("유저가 현재 모임방에서 마지막으로 활동한 페이지(= currentPage) 를 반환한다.") + void get_current_page_of_user_test() { + //given + UserRoom ur = createUserRoom(1L, 123L, 10L, UserRoomRole.MEMBER); + RoomParticipants participants = RoomParticipants.from(List.of(ur)); + + //when + int page = participants.getCurrentPageOfUser(123L); + + //then + assertEquals(0, page); + } + + @Test + @DisplayName("현재 모임방에 속하지 않는 유저가 getCurrentPageOfUser 메서드를 호출하면, InvalidStateException이 발생한다.") + void get_current_page_of_user_not_belong_test() { + //given + UserRoom ur = createUserRoom(1L, 123L, 10L, UserRoomRole.MEMBER); + RoomParticipants participants = RoomParticipants.from(List.of(ur)); + + //when & then + assertThrows(InvalidStateException.class, () -> participants.getCurrentPageOfUser(999L)); + } + + @Test + @DisplayName("유저가 현재 모임방에서 활동한 percentage(= userPercentage) 를 반환한다.") + void get_user_percentage_of_user_test() { + //given + UserRoom ur = createUserRoom(1L, 123L, 10L, UserRoomRole.MEMBER); + RoomParticipants participants = RoomParticipants.from(List.of(ur)); + + //when + double percentage = participants.getUserPercentageOfUser(123L); + + //then + assertEquals(0.0, percentage); + } + + @Test + @DisplayName("현재 모임방에 속하지 않는 유저가 getUserPercentageOfUser 메서드를 호출하면, InvalidStateException이 발생한다.") + void get_user_percentage_of_user_not_belong_test() { + //given + UserRoom ur = createUserRoom(1L, 123L, 10L, UserRoomRole.MEMBER); + RoomParticipants participants = RoomParticipants.from(List.of(ur)); + + //when & then + assertThrows(InvalidStateException.class, () -> participants.getUserPercentageOfUser(999L)); + } }