From 6865dc28fc6c6f639a429bd766d5fc6de6577c7a Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 9 Jul 2025 20:26:47 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]=20=EB=B0=A9=20=EA=B2=80=EC=83=89=20a?= =?UTF-8?q?pi=20controller=20=EA=B0=9C=EB=B0=9C=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/RoomQueryController.java | 16 ++++++++++++++ .../in/web/response/RoomSearchResponse.java | 22 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 src/main/java/konkuk/thip/room/adapter/in/web/response/RoomSearchResponse.java 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 6c9e1216c..7e417d182 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,10 +1,26 @@ package konkuk.thip.room.adapter.in.web; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.application.port.in.RoomSearchUseCase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor public class RoomQueryController { + private final RoomSearchUseCase roomSearchUseCase; + + @GetMapping("/rooms/search") + public BaseResponse searchRooms( + @RequestParam(value = "keyword", required = false, defaultValue = "") final String keyword, + @RequestParam(value = "category", required = false, defaultValue = "") final String category, + @RequestParam("sort") final String sort, + @RequestParam("page") final int page + ) { + return BaseResponse.ok(roomSearchUseCase.searchRoom(keyword, category, sort, page)); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomSearchResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomSearchResponse.java new file mode 100644 index 000000000..ac5eb3197 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomSearchResponse.java @@ -0,0 +1,22 @@ +package konkuk.thip.room.adapter.in.web.response; + +import java.util.List; + +public record RoomSearchResponse( + List roomList, + int page, // 현재 페이지 + int size, // 현재 페이지에 포함된 데이터 수 + boolean last, + boolean first +) { + + public record RoomSearchResult( + Long roomId, + String bookImageUrl, + String roomName, + int memberCount, + int recruitCount, + String deadlineDate, + String category + ) {} +} From 33d392dc46cb42a18beac0513e6b5a3b5d63a51f Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 9 Jul 2025 20:27:55 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[feat]=20=EB=B0=A9=20=EA=B2=80=EC=83=89=20a?= =?UTF-8?q?pi=20use=20case=20=EA=B0=9C=EB=B0=9C=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/out/persistence/CategoryName.java | 32 +++++++ .../out/persistence/RoomSearchSortParam.java | 26 ++++++ .../port/in/RoomSearchUseCase.java | 8 ++ .../application/port/out/RoomQueryPort.java | 6 ++ .../service/RoomSearchService.java | 84 +++++++++++++++++++ 5 files changed, 156 insertions(+) create mode 100644 src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryName.java create mode 100644 src/main/java/konkuk/thip/room/adapter/out/persistence/RoomSearchSortParam.java create mode 100644 src/main/java/konkuk/thip/room/application/port/in/RoomSearchUseCase.java create mode 100644 src/main/java/konkuk/thip/room/application/service/RoomSearchService.java diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryName.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryName.java new file mode 100644 index 000000000..697b7d653 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryName.java @@ -0,0 +1,32 @@ +package konkuk.thip.room.adapter.out.persistence; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum CategoryName { + + /** + * DB에 저장되어 있는 모든 카테고리들의 이름 + * TODO : DB에서 value를 통해 카테고리를 조회하는것보다 id로 조회하는게 성능상 좋으니, id 값도 같이 보관 ?? + */ + SCIENCE_IT("과학/IT"), + Literature("문학"), + ART("예술"), + SOCIAL_SCIENCE("사회과확"), + HUMANITY("인문학"); + + private final String value; + + public static CategoryName from(String value) { + return Arrays.stream(CategoryName.values()) + .filter(categoryName -> categoryName.getValue().equals(value)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("현재 카테고리 이름 : " + value) + ); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomSearchSortParam.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomSearchSortParam.java new file mode 100644 index 000000000..5c676fba2 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomSearchSortParam.java @@ -0,0 +1,26 @@ +package konkuk.thip.room.adapter.out.persistence; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; + +@Getter +@RequiredArgsConstructor +public enum RoomSearchSortParam { + + DEADLINE("deadline"), + MEMBER_COUNT("memberCount"), + RECOMMEND("인플루언서, 작가 추천"); // 개발 미정 + + private final String value; + + public static RoomSearchSortParam from(String value) { + return Arrays.stream(RoomSearchSortParam.values()) + .filter(param -> param.getValue().equals(value)) + .findFirst() + .orElseThrow( + () -> new IllegalArgumentException("현재 정렬 조건 param : " + value) + ); + } +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomSearchUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomSearchUseCase.java new file mode 100644 index 000000000..d7ae4c94c --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomSearchUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; + +public interface RoomSearchUseCase { + + RoomSearchResponse searchRoom(String keyword, String category, String sort, 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 74602764c..1cd001ab1 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,7 +1,13 @@ package konkuk.thip.room.application.port.out; +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 RoomQueryPort { int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate); + + Page searchRoom(String keyword, String category, Pageable pageable); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java b/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java new file mode 100644 index 000000000..dfe1d72bc --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java @@ -0,0 +1,84 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import konkuk.thip.room.adapter.out.persistence.CategoryName; +import konkuk.thip.room.adapter.out.persistence.RoomSearchSortParam; +import konkuk.thip.room.application.port.in.RoomSearchUseCase; +import konkuk.thip.room.application.port.out.RoomQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; + +import static konkuk.thip.common.exception.code.ErrorCode.CATEGORY_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_ROOM_SEARCH_SORT; + +@Service +@RequiredArgsConstructor +public class RoomSearchService implements RoomSearchUseCase { + + private static final int DEFAULT_PAGE_SIZE = 10; + + private final RoomQueryPort roomQueryPort; + + @Override + public RoomSearchResponse searchRoom(String keyword, String category, String sort, int page) { + // 1. validation + String sortVal = validateSort(sort); + String categoryVal = validateCategory(category); + + // 2. Pageable 생성 + int pageIndex = page > 0 ? page - 1 : 0; + Pageable pageable = PageRequest.of(pageIndex, DEFAULT_PAGE_SIZE, buildSort(sortVal)); + + // 3. 방 검색 + Page result = roomQueryPort.searchRoom(keyword, categoryVal, pageable); + + // 4. response 구성 + return new RoomSearchResponse( + result.getContent(), + page, + result.getNumberOfElements(), + result.isLast(), + result.isFirst()); + } + + private String validateSort(String sort) { + try { + return RoomSearchSortParam.from(sort).getValue(); + } catch (IllegalArgumentException ex) { + throw new BusinessException(INVALID_ROOM_SEARCH_SORT, ex); + } + } + + private String validateCategory(String category) { + if (category == null || category.isEmpty()) { + return ""; + } + try { + CategoryName cat = CategoryName.from(category); + return cat.getValue(); + } catch (IllegalArgumentException ex) { + throw new BusinessException(CATEGORY_NOT_FOUND, ex); + } + } + + /** + * 정렬 키에 따른 Sort 객체 생성 + */ + private Sort buildSort(String sortVal) { + if (sortVal.equals(RoomSearchSortParam.MEMBER_COUNT.getValue())) { + // 인기순: 참여자 수 내림차순 + return Sort.by(Sort.Direction.DESC, RoomSearchSortParam.MEMBER_COUNT.getValue()); + } + if (sortVal.equals(RoomSearchSortParam.RECOMMEND.getValue())) { + // TODO: 추후 추천 로직 구현 시 반영 + return Sort.unsorted(); + } + // default: 마감 임박순(deadLine) = 시작일 빠른 순서대로 오름차순 + return Sort.by(Sort.Direction.ASC, RoomSearchSortParam.DEADLINE.getValue()); + } +} From 3effb9f338328ba367f7e81b856e22c674e83518 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 9 Jul 2025 20:28:14 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[feat]=20errorCode=20=EC=B6=94=EA=B0=80=20(?= =?UTF-8?q?#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/konkuk/thip/common/exception/code/ErrorCode.java | 1 + 1 file changed, 1 insertion(+) 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 03df7c30e..3d9b9f7e0 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -68,6 +68,7 @@ public enum ErrorCode implements ResponseCode { */ ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 100000, "존재하지 않는 ROOM 입니다."), INVALID_ROOM_CREATE(HttpStatus.BAD_REQUEST, 100001, "유효하지 않은 ROOM 생성 요청 입니다."), + INVALID_ROOM_SEARCH_SORT(HttpStatus.BAD_REQUEST, 100002, "방 검색 시 정렬 조건이 잘못되었습니다."), /** * 110000 : vote error From 718dfca1a5053a87f04a73d35f8e1a192de9e4d7 Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 9 Jul 2025 20:28:51 +0900 Subject: [PATCH 4/5] =?UTF-8?q?[feat]=20=EB=B0=A9=20=EA=B2=80=EC=83=89=20a?= =?UTF-8?q?pi=20=EC=98=81=EC=86=8D=EC=84=B1=20adapter=20=EA=B0=9C=EB=B0=9C?= =?UTF-8?q?=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QueryDsl 사용해서 검색 로직 구현 --- .../konkuk/thip/common/util/DateUtilsss.java | 31 ++++ .../out/persistence/RoomJpaRepository.java | 5 +- .../RoomQueryPersistenceAdapter.java | 8 + .../out/persistence/RoomQueryRepository.java | 10 ++ .../persistence/RoomQueryRepositoryImpl.java | 140 ++++++++++++++++++ 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 src/main/java/konkuk/thip/common/util/DateUtilsss.java create mode 100644 src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java create mode 100644 src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java diff --git a/src/main/java/konkuk/thip/common/util/DateUtilsss.java b/src/main/java/konkuk/thip/common/util/DateUtilsss.java new file mode 100644 index 000000000..e8d1a857e --- /dev/null +++ b/src/main/java/konkuk/thip/common/util/DateUtilsss.java @@ -0,0 +1,31 @@ +package konkuk.thip.common.util; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.LocalDateTime; + +public class DateUtilsss { + + public static String formatAfterTime(LocalDate date) { + LocalDateTime now = LocalDateTime.now(); + LocalDateTime dateTime = date.atStartOfDay(); + Duration d = Duration.between(now, dateTime); + + if (d.isNegative() || d.isZero()) { + return "??"; + } + + long days = d.toDays(); + if (days > 0) { + return days + "일 뒤 "; + } + + long hours = d.toHours(); + if (hours > 0) { + return hours + "시간 뒤 "; + } + + long minutes = d.toMinutes(); + return minutes + "분 뒤 "; + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomJpaRepository.java index a5353bcf8..f725faba2 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomJpaRepository.java @@ -5,6 +5,9 @@ import java.time.LocalDate; -public interface RoomJpaRepository extends JpaRepository { +public interface RoomJpaRepository extends JpaRepository, RoomQueryRepository { + int countByBookJpaEntity_BookIdAndStartDateAfter(Long bookId, LocalDate currentDate); + + } 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 e14af686b..e2e50f8c4 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,8 +1,11 @@ package konkuk.thip.room.adapter.out.persistence; +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 lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; import java.time.LocalDate; @@ -18,4 +21,9 @@ public class RoomQueryPersistenceAdapter implements RoomQueryPort { public int countRecruitingRoomsByBookAndStartDateAfter(Long bookId, LocalDate currentDate) { return roomJpaRepository.countByBookJpaEntity_BookIdAndStartDateAfter(bookId, currentDate); } + + @Override + public Page searchRoom(String keyword, String category, Pageable pageable) { + return roomJpaRepository.searchRoom(keyword, category, 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 new file mode 100644 index 000000000..56c000969 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepository.java @@ -0,0 +1,10 @@ +package konkuk.thip.room.adapter.out.persistence; + +import konkuk.thip.room.adapter.in.web.response.RoomSearchResponse; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface RoomQueryRepository { + + Page searchRoom(String keyword, String category, 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 new file mode 100644 index 000000000..72d9154d7 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryRepositoryImpl.java @@ -0,0 +1,140 @@ +package konkuk.thip.room.adapter.out.persistence; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.book.adapter.out.jpa.QBookJpaEntity; +import konkuk.thip.common.util.DateUtilsss; +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; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class RoomQueryRepositoryImpl implements RoomQueryRepository { + + private final JPAQueryFactory queryFactory; + private final QRoomJpaEntity room = QRoomJpaEntity.roomJpaEntity; + 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 필터 적용, 멤버 모집중인(= 활동 시작전인) 방만 검색 + BooleanBuilder where = new BooleanBuilder(); + // keyword 필터 (빈 문자열이면 생략) + if (keyword != null && !keyword.isBlank()) { + where.and(room.title.containsIgnoreCase(keyword).or(book.title.containsIgnoreCase(keyword))); + } + // category 필터 (빈 문자열이면 생략) + if (category != null && !category.isBlank()) { + where.and(room.categoryJpaEntity.value.eq(category)); + } + // 모집중인 방만 + where.and(room.startDate.after(LocalDate.now())); + + // 2. 페이징된 content 조회 + // 우선순위 표현식 : keyword가 방 제목에 매칭되면 1, 아니면 0 + NumberExpression priorityExpr = new CaseBuilder() + .when(room.title.containsIgnoreCase(keyword)).then(1) + .otherwise(0); + + NumberExpression memberCountExpr = userRoom.userRoomId.count(); // 방 별 멤버수 표현식 + + List tuples = queryFactory + .select( + room.roomId, + book.imageUrl, + room.title, + memberCountExpr, + room.recruitCount, + room.startDate, + room.categoryJpaEntity.value + ) + .from(room) + .join(room.bookJpaEntity, book) + .leftJoin(userRoom).on(userRoom.roomJpaEntity.eq(room)) + .where(where) + .groupBy( + room.roomId, + book.imageUrl, + room.title, + room.recruitCount, + room.startDate, + room.categoryJpaEntity.value + ) + .orderBy( + // 1차 정렬 : 설정된 정렬 조건, 2차 정렬 : 방이름으로 방 검색 > 책제목으로 방 검색 + toOrderSpecifier(pageable.getSort(), room, memberCountExpr), + priorityExpr.desc() + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + // TODO : 추후에 오프셋 페이징이 아니라, 키셋 페이징 기법 도입 검토 + + // 3. Tuple → DTO 매핑 + List content = tuples.stream() + .map(t -> new RoomSearchResponse.RoomSearchResult( + t.get(room.roomId), + t.get(book.imageUrl), + t.get(room.title), + // 참여자 수를 int로 캐스팅 + t.get(memberCountExpr).intValue(), + t.get(room.recruitCount), + // 모집마감일 까지 남은 시간 포맷 + DateUtilsss.formatAfterTime(t.get(room.startDate)), + t.get(room.categoryJpaEntity.value) + )) + .toList(); + + // 4. 전체 개수 조회 (페이징 정보 계산용) + Long totalCount = queryFactory + .select(room.count()) + .from(room) + .join(room.bookJpaEntity, book) + .where(where) + .fetchOne(); + long total = (totalCount != null) ? totalCount : 0L; + + // 5. PageImpl 생성하여 반환 + return new PageImpl<>(content, pageable, total); + } + + /** + * 지원하는 정렬 키에 대해, 미리 정의된 Q 필드를 반환합니다. + * sort가 없으면 '마감 임박순' 으로 기본 처리합니다. + */ + private OrderSpecifier toOrderSpecifier(Sort sort, QRoomJpaEntity room, NumberExpression memberCountExpr) { + // sort 파라미터가 없으면 기본 마감 임박순 + if (sort.isUnsorted()) { + return room.startDate.asc(); + } + + // 클라이언트가 보낸 첫 번째 sort 키를 꺼냅니다. + String key = sort.stream().findFirst().get().getProperty(); + + switch (key) { + case "memberCount": + // user_rooms 테이블에서 현재 참여자 수 집계 → 내림차순 + return new OrderSpecifier<>(Order.DESC, memberCountExpr); + case "deadLine": + default: + // deadLine: 마감 임박순 = startDate 빠른 순서대로(오름차순) + return room.startDate.asc(); + } + } +} From 29747ee33a429397fdc8d07d245f27b2970464cf Mon Sep 17 00:00:00 2001 From: seongjunnoh Date: Wed, 9 Jul 2025 20:29:08 +0900 Subject: [PATCH 5/5] =?UTF-8?q?[test]=20=EB=B0=A9=20=EA=B2=80=EC=83=89=20a?= =?UTF-8?q?pi=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20(#55)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/in/web/RoomSearchApiTest.java | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java new file mode 100644 index 000000000..bba425dba --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomSearchApiTest.java @@ -0,0 +1,366 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.BookJpaRepository; +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.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.hamcrest.Matchers.is; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 방 검색 api 통합 테스트") +class RoomSearchApiTest { + + @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 saveRoom(String categoryValue, String bookTitle, String isbn, String roomName, LocalDate startDate, int recruitCount) { + AliasJpaEntity alias = aliasJpaRepository.save(AliasJpaEntity.builder() + .value("소설-칭호") + .color("blue") + .imageUrl("http://image.url") + .build()); + + 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(CategoryJpaEntity.builder() + .value(categoryValue) + .aliasForCategoryJpaEntity(alias) + .build()); + + 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(AliasJpaEntity.builder() + .value("소설-칭호") + .color("blue") + .imageUrl("http://image.url") + .build()); + + // 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("keyword = [과학], 카테고리 선택 X, 정렬 = [마감임박순] 일 경우, 방이름 or 책제목에 '과학'이 포함된 방 검색 결과가 마감임박순으로 반환된다.") + void search_keyword_and_sort_deadline() throws Exception { + //given + RoomJpaEntity science_room_1 = saveRoom("과학/IT", "과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_1, 4); + + RoomJpaEntity science_room_2 = saveRoom("과학/IT", "과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_2, 5); + + RoomJpaEntity science_room_3 = saveRoom("과학/IT", "과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity science_room_4 = saveRoom("과학/IT", "과학-책", "isbn4", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_4, 1); + + RoomJpaEntity room_3 = saveRoom("문학", "문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveRoom("과학/IT", "과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/search") + .requestAttr("userId", 1L) + .param("keyword", "과학") + .param("sort", "deadline") + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(5))) // 결과 리스트 크기 확인 + .andExpect(jsonPath("$.data.page", is(1))) // 페이징 정보 검증 + .andExpect(jsonPath("$.data.size", is(5))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + /** + * roomList 검증 : 정렬 순서, 방 검색 결과 검증 + * <정렬 순서> + * 1. 정렬 조건 + * 2. 정렬 조건이 같을 경우, 방이름에 keyword가 포함된 검색결과가 책제목에 keyword가 포함된 검색결과보다 우선순위가 더 높다 + */ + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("방이름입니다"))) + .andExpect(jsonPath("$.data.roomList[2].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[3].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.roomList[4].roomName", is("방제목에-과학-포함된-문학방"))); + } + + @Test + @DisplayName("keyword = [과학], 카테고리 선택 X, 정렬 = [인기순] 일 경우, 방이름 or 책제목에 '과학'이 포함된 방 검색 결과가 인기순(= 현재까지 모집된 인원 많은 순)으로 반환된다.") + void search_keyword_and_sort_member_count() throws Exception { + //given + RoomJpaEntity science_room_1 = saveRoom("과학/IT", "과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_1, 4); + + RoomJpaEntity science_room_2 = saveRoom("과학/IT", "과학-책", "isbn2", "방이름입니다", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_2, 5); + + RoomJpaEntity science_room_3 = saveRoom("과학/IT", "과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity science_room_4 = saveRoom("과학/IT", "과학-책", "isbn4", "과학-방-5일뒤-활동시작", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_4, 1); + + RoomJpaEntity room_3 = saveRoom("문학", "문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveRoom("과학/IT", "과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/search") + .requestAttr("userId", 1L) + .param("keyword", "과학") + .param("sort", "memberCount") + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(5))) // 결과 리스트 크기 확인 + .andExpect(jsonPath("$.data.page", is(1))) // 페이징 정보 검증 + .andExpect(jsonPath("$.data.size", is(5))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + /** + * roomList 검증 : 정렬 순서, 방 검색 결과 검증 + * <정렬 순서> + * 1. 정렬 조건 + * 2. 정렬 조건이 같을 경우, 방이름에 keyword가 포함된 검색결과가 책제목에 keyword가 포함된 검색결과보다 우선순위가 더 높다 + */ + .andExpect(jsonPath("$.data.roomList[0].roomName", is("방제목에-과학-포함된-문학방"))) + .andExpect(jsonPath("$.data.roomList[0].memberCount", is(6))) + + .andExpect(jsonPath("$.data.roomList[1].roomName", is("방이름입니다"))) + .andExpect(jsonPath("$.data.roomList[1].memberCount", is(5))) + + .andExpect(jsonPath("$.data.roomList[2].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[2].memberCount", is(4))) + + .andExpect(jsonPath("$.data.roomList[3].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.roomList[3].memberCount", is(2))) + + .andExpect(jsonPath("$.data.roomList[4].roomName", is("과학-방-5일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[4].memberCount", is(1))); + } + + @Test + @DisplayName("keyword 입력 x, 카테고리 = [과학/IT], 정렬 = [마감임박순] 일 경우, [과학/IT] 카테고리에 속하는 방 검색 결과가 반환된다.") + void search_category_and_sort_deadline() throws Exception { + //given + RoomJpaEntity science_room_1 = saveRoom("과학/IT", "과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_1, 4); + + RoomJpaEntity science_room_3 = saveRoom("과학/IT", "과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity room_3 = saveRoom("문학", "문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveRoom("과학/IT", "과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/search") + .requestAttr("userId", 1L) + .param("category", "과학/IT") + .param("sort", "deadline") + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) // 결과 리스트 크기 확인 + .andExpect(jsonPath("$.data.page", is(1))) // 페이징 정보 검증 + .andExpect(jsonPath("$.data.size", is(2))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + /** + * roomList 검증 : 정렬 순서, 방 검색 결과 검증 + * <정렬 순서> + * 1. 정렬 조건 + * 2. 정렬 조건이 같을 경우, 방이름에 keyword가 포함된 검색결과가 책제목에 keyword가 포함된 검색결과보다 우선순위가 더 높다 + */ + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("무슨방일까요??"))); + } + + @Test + @DisplayName("keyword 입력 x, 카테고리 입력 x, 정렬 = [마감임박순] 일 경우, DB에 존재하는 전체 방 검색 결과가 반환된다.") + void search_sort_deadline() throws Exception { + //given + RoomJpaEntity science_room_1 = saveRoom("과학/IT", "과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_1, 4); + + RoomJpaEntity science_room_3 = saveRoom("과학/IT", "과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity room_3 = saveRoom("문학", "문학-책", "isbn5", "방제목에-과학-포함된-문학방", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveRoom("과학/IT", "과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + //when + ResultActions result = mockMvc.perform(get("/rooms/search") + .requestAttr("userId", 1L) + .param("sort", "deadline") + .param("page", "1")); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(3))) // 결과 리스트 크기 확인 + .andExpect(jsonPath("$.data.page", is(1))) // 페이징 정보 검증 + .andExpect(jsonPath("$.data.size", is(3))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + /** + * roomList 검증 : 정렬 순서, 방 검색 결과 검증 + * <정렬 순서> + * 1. 정렬 조건 + * 2. 정렬 조건이 같을 경우, 방이름에 keyword가 포함된 검색결과가 책제목에 keyword가 포함된 검색결과보다 우선순위가 더 높다 + */ + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("무슨방일까요??"))) + .andExpect(jsonPath("$.data.roomList[2].roomName", is("방제목에-과학-포함된-문학방"))); + } + + @Test + @DisplayName("keyword=[과학], category=[과학/IT], 정렬=[마감임박순] 일 경우, keyword, category 조건을 모두 만족하는 방만 반환된다.") + void search_keyword_and_category() throws Exception { + // given + RoomJpaEntity science_room_1 = saveRoom("과학/IT", "과학-책", "isbn1", "과학-방-1일뒤-활동시작", LocalDate.now().plusDays(1), 10); + saveUsersToRoom(science_room_1, 4); + + RoomJpaEntity science_room_3 = saveRoom("과학/IT", "과학-책", "isbn3", "무슨방일까요??", LocalDate.now().plusDays(5), 8); + saveUsersToRoom(science_room_3, 2); + + RoomJpaEntity room_3 = saveRoom("문학", "문학-책", "isbn5", "문학방입니다", LocalDate.now().plusDays(10), 8); + saveUsersToRoom(room_3, 6); + + RoomJpaEntity recruit_expired_room_4 = saveRoom("과학/IT", "과학-책", "isbn6", "모집기한-지난-과학방", LocalDate.now().minusDays(1), 8); + saveUsersToRoom(recruit_expired_room_4, 6); + + // when + ResultActions result = mockMvc.perform(get("/rooms/search") + .requestAttr("userId", 1L) + .param("keyword", "과학") + .param("category", "과학/IT") + .param("sort", "deadline") + .param("page", "1")); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomList", hasSize(2))) + .andExpect(jsonPath("$.data.page", is(1))) + .andExpect(jsonPath("$.data.size", is(2))) + .andExpect(jsonPath("$.data.first", is(true))) + .andExpect(jsonPath("$.data.last", is(true))) + /** + * roomList 검증 : 정렬 순서, 방 검색 결과 검증 + * <정렬 순서> + * 1. 정렬 조건 + * 2. 정렬 조건이 같을 경우, 방이름에 keyword가 포함된 검색결과가 책제목에 keyword가 포함된 검색결과보다 우선순위가 더 높다 + */ + .andExpect(jsonPath("$.data.roomList[0].roomName", is("과학-방-1일뒤-활동시작"))) + .andExpect(jsonPath("$.data.roomList[1].roomName", is("무슨방일까요??"))); + } +}