diff --git a/src/main/java/konkuk/thip/common/util/DateUtil.java b/src/main/java/konkuk/thip/common/util/DateUtil.java index cd27b84f7..165b8e9da 100644 --- a/src/main/java/konkuk/thip/common/util/DateUtil.java +++ b/src/main/java/konkuk/thip/common/util/DateUtil.java @@ -3,9 +3,12 @@ import java.time.Duration; import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; public class DateUtil { + public static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + //마지막 활동 시간 포맷팅 -> ex. 1분 전, 1시간 전, 1일 전 public static String formatBeforeTime(LocalDateTime createdAt) { long minutes = Duration.between(createdAt, LocalDateTime.now()).toMinutes(); @@ -27,16 +30,20 @@ public static String formatAfterTime(LocalDate date) { long days = d.toDays(); if (days > 0) { - return days + "일 뒤 "; + return days + "일 뒤"; } long hours = d.toHours(); if (hours > 0) { - return hours + "시간 뒤 "; + return hours + "시간 뒤"; } long minutes = d.toMinutes(); - return minutes + "분 뒤 "; + return minutes + "분 뒤"; + } + + public static String formatDate(LocalDate date) { + return date.format(DATE_FORMATTER); } } 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 7e85943c4..4d68835cb 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,12 +2,14 @@ import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; +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.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.RoomShowRecruitingDetailViewUseCase; import konkuk.thip.room.application.port.in.RoomVerifyPasswordUseCase; import konkuk.thip.room.application.port.in.dto.RoomGetHomeJoinedListQuery; import lombok.RequiredArgsConstructor; @@ -21,8 +23,9 @@ public class RoomQueryController { private final RoomSearchUseCase roomSearchUseCase; - private final RoomGetHomeJoinedListUseCase roomGetHomeJoinedListUseCase; private final RoomVerifyPasswordUseCase roomVerifyPasswordUseCase; + private final RoomShowRecruitingDetailViewUseCase roomShowRecruitingDetailViewUseCase; + private final RoomGetHomeJoinedListUseCase roomGetHomeJoinedListUseCase; @GetMapping("/rooms/search") public BaseResponse searchRooms( @@ -42,6 +45,13 @@ 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, + @PathVariable("roomId") final Long roomId) { + return BaseResponse.ok(roomShowRecruitingDetailViewUseCase.getRecruitingRoomDetailView(userId, roomId)); + } + //[모임 홈] 참여중인 내 모임방 조회 @GetMapping("/rooms/home/joined") public BaseResponse getHomeJoinedRooms(@UserId final Long userId, diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomRecruitingDetailViewResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomRecruitingDetailViewResponse.java new file mode 100644 index 000000000..f54901be7 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomRecruitingDetailViewResponse.java @@ -0,0 +1,36 @@ +package konkuk.thip.room.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +public record RoomRecruitingDetailViewResponse( + boolean isHost, + boolean isJoining, + Long roomId, + String roomName, + String roomImageUrl, + boolean isPublic, + String progressStartDate, + String progressEndDate, + String recruitEndDate, + String category, + String roomDescription, + int memberCount, + int recruitCount, + String isbn, + String bookImageUrl, + String bookTitle, + String authorName, + String bookDescription, + List recommendRooms +) { + @Builder + public record RecommendRoom( + String roomImageUrl, + String roomName, + int memberCount, + int recruitCount, + String recruitEndDate + ) {} +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/CategoryJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/CategoryJpaEntity.java index f9a2fed80..f072818ce 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/CategoryJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/CategoryJpaEntity.java @@ -22,6 +22,9 @@ public class CategoryJpaEntity extends BaseJpaEntity { @Column(name = "category_value",length = 50, nullable = false) private String value; + @Column(name = "image_url", columnDefinition = "TEXT", nullable = false) + private String imageUrl; + @OneToOne(fetch = FetchType.LAZY) @JoinColumn(name = "category_alias_id", nullable = false) private AliasJpaEntity aliasForCategoryJpaEntity; 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 ba93f0189..2e58df281 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,15 +1,18 @@ package konkuk.thip.room.adapter.out.persistence; +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.application.port.out.RoomQueryPort; +import konkuk.thip.room.domain.Room; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.time.LocalDate; +import java.util.List; @Repository @RequiredArgsConstructor @@ -28,6 +31,11 @@ public Page searchRoom(String keyword, Stri return roomJpaRepository.searchRoom(keyword, category, pageable); } + @Override + public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count) { + return roomJpaRepository.findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(currentRoom.getId(), currentRoom.getCategory().getValue(), count); + } + @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 651e4cd0e..f0b5a3feb 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,14 +1,19 @@ package konkuk.thip.room.adapter.out.persistence; +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 org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import java.util.List; + import java.time.LocalDate; 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); } 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 c6a5b7249..469e4cd1a 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 @@ -11,6 +11,7 @@ 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.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; @@ -143,6 +144,34 @@ private OrderSpecifier toOrderSpecifier(Sort sort, QRoomJpaEntity room, Numbe } } + @Override + public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, String category, int count) { + NumberExpression memberCountExpr = userRoom.userRoomId.count(); + List tuples = queryFactory + .select(room.roomId, room.title, memberCountExpr, room.recruitCount, room.startDate) + .from(room) + .leftJoin(userRoom).on(userRoom.roomJpaEntity.eq(room)) + .where( + room.categoryJpaEntity.value.eq(category) + .and(room.startDate.after(LocalDate.now())) // 모집 마감 시각 > 현재 시각 + .and(room.roomId.ne(roomId)) // 현재 방 제외 + ) + .groupBy(room.roomId, room.title, room.recruitCount, room.startDate) + .orderBy(room.startDate.asc()) + .limit(count) + .fetch(); + + return tuples.stream() + .map(t -> RoomRecruitingDetailViewResponse.RecommendRoom.builder() + .roomImageUrl(null) // roomImageUrl은 추후 구현 + .roomName(t.get(room.title)) + .memberCount(t.get(memberCountExpr).intValue()) + .recruitCount(t.get(room.recruitCount)) + .recruitEndDate(DateUtil.formatAfterTime(t.get(room.startDate))) + .build()) + .toList(); + } + @Override public Page searchHomeJoinedRooms(Long userId, LocalDate date, Pageable pageable) { diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomShowRecruitingDetailViewUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomShowRecruitingDetailViewUseCase.java new file mode 100644 index 000000000..056429d99 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomShowRecruitingDetailViewUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.adapter.in.web.response.RoomRecruitingDetailViewResponse; + +public interface RoomShowRecruitingDetailViewUseCase { + + RoomRecruitingDetailViewResponse getRecruitingRoomDetailView(Long userId, Long roomId); +} 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 8d55f98a7..b5032e3de 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,14 +1,19 @@ package konkuk.thip.room.application.port.out; +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.domain.Room; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import java.time.LocalDate; +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); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java b/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java new file mode 100644 index 000000000..c79c70d17 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java @@ -0,0 +1,78 @@ +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.RoomRecruitingDetailViewResponse; +import konkuk.thip.room.application.port.in.RoomShowRecruitingDetailViewUseCase; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.room.domain.Room; +import konkuk.thip.user.application.port.out.UserRoomCommandPort; +import konkuk.thip.user.domain.UserRoom; +import konkuk.thip.user.domain.RoomParticipants; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class RoomShowRecruitingDetailViewService implements RoomShowRecruitingDetailViewUseCase { + + private final static int RECOMMEND_ROOM_COUNT = 5; + + private final RoomCommandPort roomCommandPort; + private final RoomQueryPort roomQueryPort; + private final BookCommandPort bookCommandPort; + private final UserRoomCommandPort userRoomCommandPort; + + @Override + @Transactional(readOnly = true) + public RoomRecruitingDetailViewResponse getRecruitingRoomDetailView(Long userId, Long roomId) { + // 1. Room 조회, Book 조회 + Room room = roomCommandPort.findById(roomId); + Book book = bookCommandPort.findById(room.getBookId()); + + // 2. Room과 연관된 UserRoom 조회, RoomParticipants 일급 컬렉션 생성 + List findByRoomId = userRoomCommandPort.findAllByRoomId(roomId); + RoomParticipants roomParticipants = RoomParticipants.from(findByRoomId); + + // 3. 다른 모임방 추천 + List recommendRooms = roomQueryPort.findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(room, RECOMMEND_ROOM_COUNT); + + // 4. response 구성 + return buildResponse(userId, room, book, roomParticipants, recommendRooms); + } + + private RoomRecruitingDetailViewResponse buildResponse( + Long userId, + Room room, + Book book, + RoomParticipants participants, + List recommendRooms + ) { + return new RoomRecruitingDetailViewResponse( + participants.isHostOfRoom(userId), + participants.isJoiningToRoom(userId), + room.getId(), + room.getTitle(), + room.getCategory().getImageUrl(), + room.isPublic(), + DateUtil.formatDate(room.getStartDate()), + DateUtil.formatDate(room.getEndDate()), + DateUtil.formatAfterTime(room.getStartDate()), + room.getCategory().getValue(), + room.getDescription(), + participants.calculateMemberCount(), + room.getRecruitCount(), + book.getIsbn(), + book.getImageUrl(), + book.getTitle(), + book.getAuthorName(), + book.getDescription(), + recommendRooms + ); + } +} diff --git a/src/main/java/konkuk/thip/room/domain/Category.java b/src/main/java/konkuk/thip/room/domain/Category.java index 1ff96bba6..7187e2735 100644 --- a/src/main/java/konkuk/thip/room/domain/Category.java +++ b/src/main/java/konkuk/thip/room/domain/Category.java @@ -16,13 +16,14 @@ public enum Category { * DB에 저장되어 있는 모든 카테고리들의 이름 * TODO : DB에서 value를 통해 카테고리를 조회하는것보다 id로 조회하는게 성능상 좋으니, id 값도 같이 보관 ?? */ - SCIENCE_IT("과학/IT"), - LITERATURE("문학"), - ART("예술"), - SOCIAL_SCIENCE("사회과학"), - HUMANITY("인문학"); + SCIENCE_IT("과학/IT", "과학/IT_image"), + LITERATURE("문학", "문학_image"), + ART("예술", "예술_image"), + SOCIAL_SCIENCE("사회과학", "사회과학_image"), + HUMANITY("인문학", "인문학_image"); private final String value; + private final String imageUrl; public static Category from(String value) { return Arrays.stream(Category.values()) diff --git a/src/main/java/konkuk/thip/user/domain/RoomParticipants.java b/src/main/java/konkuk/thip/user/domain/RoomParticipants.java new file mode 100644 index 000000000..bceacbf17 --- /dev/null +++ b/src/main/java/konkuk/thip/user/domain/RoomParticipants.java @@ -0,0 +1,36 @@ +package konkuk.thip.user.domain; + +import konkuk.thip.user.adapter.out.jpa.UserRoomRole; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.List; + +@Getter +@RequiredArgsConstructor +public class RoomParticipants { + /** + * 특정 Room 과 연관된 UserRoom 들을 모은 일급 컬렉션 + */ + + private final List participants; + + public static RoomParticipants from(List participants) { + return new RoomParticipants(participants); + } + + public int calculateMemberCount() { + return participants.size(); + } + + public boolean isJoiningToRoom(Long userId) { + return participants.stream() + .anyMatch(userRoom -> userRoom.getUserId().equals(userId)); + } + + public boolean isHostOfRoom(Long userId) { + return participants.stream() + .filter(userRoom -> userRoom.getUserId().equals(userId)) + .anyMatch(userRoom -> userRoom.getUserRoomRole().equals(UserRoomRole.HOST.getType())); + } +} diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index ffd760b10..3ebebee79 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -28,6 +28,7 @@ public static AliasJpaEntity createLiteratureAlias() { public static CategoryJpaEntity createLiteratureCategory(AliasJpaEntity alias) { return CategoryJpaEntity.builder() // 실제 존재하는 값으로 .value("문학") + .imageUrl("문학_image") .aliasForCategoryJpaEntity(alias) .build(); } @@ -43,6 +44,7 @@ public static AliasJpaEntity createScienceAlias() { public static CategoryJpaEntity createScienceCategory(AliasJpaEntity alias) { return CategoryJpaEntity.builder() // 실제 존재하는 값으로 .value("과학/IT") + .imageUrl("과학/IT_image") .aliasForCategoryJpaEntity(alias) .build(); } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java new file mode 100644 index 000000000..55b0d2a9d --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomRecruitingDetailViewApiTest.java @@ -0,0 +1,341 @@ +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.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 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 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 RoomRecruitingDetailViewApiTest { + + @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; + + @AfterEach + void tearDown() { + 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 RoomJpaEntity saveLiteratureRoom(String bookTitle, String isbn, String roomName, LocalDate startDate, int recruitCount) { + AliasJpaEntity alias = aliasJpaRepository.save(TestEntityFactory.createLiteratureAlias()); + + 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.createLiteratureCategory(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); + } + + @Test + @DisplayName("모집중인 모임방 상세조회할 경우, 해당 모임방의 정보, 책 정보, 추천할 모임방의 정보를 반환한다.") + void get_recruiting_room_detail() throws Exception { + //given + RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(targetRoom, 4); + UserJpaEntity joiningUser = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + + RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_2, 5); + + RoomJpaEntity science_room_3 = saveScienceRoom("과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity science_room_4 = saveScienceRoom("과학-책", "isbn4", "과학-방-8일뒤-활동시작", LocalDate.now().plusDays(8), 8); + saveUsersToRoom(science_room_4, 1); + + RoomJpaEntity room_3 = saveLiteratureRoom("문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveScienceRoom("과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/recruiting", targetRoom.getRoomId()) + .requestAttr("userId", joiningUser.getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.isJoining", is(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.recommendRooms", hasSize(3))) + /** + * recommendRooms 검증 : 현재 조회하는 방과 동일한 카테고리의 다른 방을 추천 + * <정렬 순서> : 모집 마감 임박 순 + */ + .andExpect(jsonPath("$.data.recommendRooms[0].roomName", is("방이름입니다"))) + .andExpect(jsonPath("$.data.recommendRooms[1].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.recommendRooms[2].roomName", is("과학-방-8일뒤-활동시작"))); + } + + @Test + @DisplayName("모임방의 호스트가 조회할 경우, 유저가 해당 방의 호스트임을 응답값으로 보여준다. (나머지 응답값은 동일)") + void get_recruiting_room_detail_host() throws Exception { + //given + RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(targetRoom, 4); + UserRoomJpaEntity firstMember = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1); + userRoomJpaRepository.delete(firstMember); + UserRoomJpaEntity roomCreator = userRoomJpaRepository.save(UserRoomJpaEntity.builder() + .userJpaEntity(firstMember.getUserJpaEntity()) + .roomJpaEntity(firstMember.getRoomJpaEntity()) + .userRoomRole(UserRoomRole.HOST) + .build()); // firstMember 을 MEMBER -> HOST 로 수정 + + RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_2, 5); + + RoomJpaEntity science_room_3 = saveScienceRoom("과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity science_room_4 = saveScienceRoom("과학-책", "isbn4", "과학-방-8일뒤-활동시작", LocalDate.now().plusDays(8), 8); + saveUsersToRoom(science_room_4, 1); + + RoomJpaEntity room_3 = saveLiteratureRoom("문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveScienceRoom("과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/recruiting", targetRoom.getRoomId()) + .requestAttr("userId", roomCreator.getUserJpaEntity().getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(true))) + .andExpect(jsonPath("$.data.isJoining", is(true))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .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.recommendRooms", hasSize(3))) + /** + * recommendRooms 검증 : 현재 조회하는 방과 동일한 카테고리의 다른 방을 추천 + * <정렬 순서> : 모집 마감 임박 순 + */ + .andExpect(jsonPath("$.data.recommendRooms[0].roomName", is("방이름입니다"))) + .andExpect(jsonPath("$.data.recommendRooms[1].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.recommendRooms[2].roomName", is("과학-방-8일뒤-활동시작"))); + } + + @Test + @DisplayName("추천하는 다른 모집중인 모임방이 많을 경우, 모집 기한 마감임박 순으로 최대 5개만 반환한다.") + void get_recruiting_room_detail_too_many_recommend_rooms() throws Exception { + //given + RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(targetRoom, 4); + UserJpaEntity joiningUser = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + + RoomJpaEntity science_room_2 = saveScienceRoom("과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_2, 5); + + RoomJpaEntity science_room_3 = saveScienceRoom("과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity science_room_4 = saveScienceRoom("과학-책", "isbn4", "과학-방-8일뒤-활동시작", LocalDate.now().plusDays(8), 8); + saveUsersToRoom(science_room_4, 1); + + RoomJpaEntity science_room_5 = saveScienceRoom("과학-책", "isbn5", "과학-방-10일뒤-활동시작", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(science_room_5, 1); + + RoomJpaEntity science_room_6 = saveScienceRoom("과학-책", "isbn6", "과학-방-15일뒤-활동시작", LocalDate.now().plusDays(15), 8); + saveUsersToRoom(science_room_6, 1); + + RoomJpaEntity science_room_7 = saveScienceRoom("과학-책", "isbn7", "과학-방-20일뒤-활동시작", LocalDate.now().plusDays(20), 8); + saveUsersToRoom(science_room_7, 1); + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/recruiting", targetRoom.getRoomId()) + .requestAttr("userId", joiningUser.getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.isJoining", is(true))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .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.recommendRooms", hasSize(5))) + /** + * recommendRooms 검증 : 현재 조회하는 방과 동일한 카테고리의 다른 방을 추천 + * <정렬 순서> : 모집 마감 임박 순 + */ + .andExpect(jsonPath("$.data.recommendRooms[0].roomName", is("방이름입니다"))) + .andExpect(jsonPath("$.data.recommendRooms[1].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.recommendRooms[2].roomName", is("과학-방-8일뒤-활동시작"))) + .andExpect(jsonPath("$.data.recommendRooms[3].roomName", is("과학-방-10일뒤-활동시작"))) + .andExpect(jsonPath("$.data.recommendRooms[4].roomName", is("과학-방-15일뒤-활동시작"))); + } + + @Test + @DisplayName("추천하는 다른 모집중인 모임방이 없을 경우, 해당 데이터를 빈 배열로 반환한다.") + void get_recruiting_room_detail_no_recommend_rooms() throws Exception { + //given + RoomJpaEntity targetRoom = saveScienceRoom("과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(targetRoom, 4); + UserJpaEntity joiningUser = userRoomJpaRepository.findAllByRoomJpaEntity_RoomId(targetRoom.getRoomId()).get(1).getUserJpaEntity(); + + RoomJpaEntity room_3 = saveLiteratureRoom("문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveScienceRoom("과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/{roomId}/recruiting", targetRoom.getRoomId()) + .requestAttr("userId", joiningUser.getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isHost", is(false))) + .andExpect(jsonPath("$.data.isJoining", is(true))) + .andExpect(jsonPath("$.data.roomName", is("과학-방-1일뒤-활동시작"))) + .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.recommendRooms", hasSize(0))); + } +} diff --git a/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java b/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java new file mode 100644 index 000000000..88fc566ed --- /dev/null +++ b/src/test/java/konkuk/thip/user/domain/RoomParticipantsTest.java @@ -0,0 +1,64 @@ +package konkuk.thip.user.domain; + +import konkuk.thip.user.adapter.out.jpa.UserRoomRole; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class RoomParticipantsTest { + + private UserRoom createUserRoom(Long id, Long userId, Long roomId, UserRoomRole role) { + return UserRoom.builder() + .id(id) + .currentPage(0) + .userPercentage(0.0) + .userRoomRole(role.getType()) + .userId(userId) + .roomId(roomId) + .build(); + } + + @Test + @DisplayName("해당 모임방에 속한 유저의 수를 반환한다.") + void calculate_member_count_test() { + //given + UserRoom ur1 = createUserRoom(1L, 100L, 10L, UserRoomRole.MEMBER); + UserRoom ur2 = createUserRoom(2L, 200L, 10L, UserRoomRole.HOST); + RoomParticipants participants = RoomParticipants.from(List.of(ur1, ur2)); + + //when + int count = participants.calculateMemberCount(); + + //then + assertEquals(2, count); + } + + @Test + @DisplayName("유저가 현재 모임방에 속하면 true, 속하지 않으면 false를 반환한다.") + void is_joining_to_room_test() { + //given + UserRoom ur = createUserRoom(1L, 123L, 10L, UserRoomRole.MEMBER); + RoomParticipants participants = RoomParticipants.from(List.of(ur)); + + //when //then + assertTrue(participants.isJoiningToRoom(123L)); + assertFalse(participants.isJoiningToRoom(999L)); + } + + @Test + @DisplayName("유저가 현재 모임방의 HOST이면 true, 아니면 false를 반환한다.") + void is_host_of_room_test() { + //given + UserRoom member = createUserRoom(1L, 1L, 5L, UserRoomRole.MEMBER); + UserRoom host = createUserRoom(2L, 2L, 5L, UserRoomRole.HOST); + RoomParticipants participants = RoomParticipants.from(List.of(member, host)); + + //when //then + assertTrue(participants.isHostOfRoom(2L)); + assertFalse(participants.isHostOfRoom(1L)); + assertFalse(participants.isHostOfRoom(3L)); + } +} diff --git a/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java b/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java index b9b127415..7d6f14167 100644 --- a/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java +++ b/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java @@ -114,6 +114,7 @@ private void saveUserAndRoom() { CategoryJpaEntity category = categoryJpaRepository.save(CategoryJpaEntity.builder() .value("과학/IT") + .imageUrl("과학/IT_image") .aliasForCategoryJpaEntity(alias) .build());