diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java new file mode 100644 index 000000000..06b61ebfb --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/CompositeBookApiAdapter.java @@ -0,0 +1,53 @@ +package konkuk.thip.book.adapter.out.api; + +import konkuk.thip.book.adapter.out.api.aladin.AladinApiClient; +import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; +import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; +import konkuk.thip.book.application.port.out.BookApiQueryPort; +import konkuk.thip.book.domain.Book; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CompositeBookApiAdapter implements BookApiQueryPort { + + private final NaverApiClient naverApiClient; + private final AladinApiClient aladinApiClient; + + @Override + public NaverBookParseResult findBooksByKeyword(String keyword, int start) { + return naverApiClient.findBooksByKeyword(keyword, start); + } + + @Override + public NaverDetailBookParseResult findDetailBookByIsbn(String isbn) { + return naverApiClient.findDetailBookByIsbn(isbn); + } + + @Override + public Integer findPageCountByIsbn(String isbn) { + return aladinApiClient.findPageCountByIsbn(isbn); + } + + @Override + public Book loadBookWithPageByIsbn(String isbn) { + // 1. naver 상세정보 조회 api 로 책 상세정보(without page) load + NaverDetailBookParseResult detailBookByKeyword = findDetailBookByIsbn(isbn); + + // 2. 알라딘으로부터 책 page 정보 load + Integer pageCount = findPageCountByIsbn(isbn); + + // 3. pageCount 정보를 포함한 Book 반환 + return Book.withoutId( + detailBookByKeyword.title(), + isbn, + detailBookByKeyword.author(), + false, // TODO : 추후 BestSeller 도입되면 고려해야함 + detailBookByKeyword.publisher(), + detailBookByKeyword.imageUrl(), + pageCount, + detailBookByKeyword.description() + ); + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/api/NaverApiClient.java similarity index 84% rename from src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java rename to src/main/java/konkuk/thip/book/adapter/out/api/NaverApiClient.java index c52747689..a66fdf175 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/NaverApiClient.java @@ -2,23 +2,20 @@ import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; -import konkuk.thip.book.application.port.out.BookApiQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @Component @RequiredArgsConstructor -public class BookApiNaverApiAdapter implements BookApiQueryPort { +public class NaverApiClient { private final NaverApiUtil naverApiUtil; - @Override public NaverBookParseResult findBooksByKeyword(String keyword, int start) { String xml = naverApiUtil.searchBook(keyword, start); // 네이버 API 호출 return NaverBookXmlParser.parseBookList(xml); // XML 파싱 + 페이징 정보 포함 } - @Override public NaverDetailBookParseResult findDetailBookByIsbn(String isbn) { String xml = naverApiUtil.detailSearchBook(isbn); // 네이버 API 호출 return NaverBookXmlParser.parseBookDetail(xml); // XML 파싱 diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java b/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java index ec3e1144e..faca3a9f2 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/NaverBookXmlParser.java @@ -11,7 +11,7 @@ import java.util.List; import org.xml.sax.InputSource; -import static konkuk.thip.common.exception.code.ErrorCode.BOOK_ISBN_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NAVER_API_ISBN_NOT_FOUND; import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NAVER_API_PARSING_ERROR; public class NaverBookXmlParser { @@ -57,7 +57,7 @@ public static NaverDetailBookParseResult parseBookDetail(String xml) { if (totalStr != null) total = Integer.parseInt(totalStr); // total이 0이면 isbn에 해당하는 책이 없음(잘못 넘어온 isbn 예외처리) - if (total == 0) throw new BusinessException(BOOK_ISBN_NOT_FOUND); + if (total == 0) throw new BusinessException(BOOK_NAVER_API_ISBN_NOT_FOUND); List items = getItemElements(channel); if (!items.isEmpty()) { diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiClient.java b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiClient.java new file mode 100644 index 000000000..3b6b12abd --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiClient.java @@ -0,0 +1,15 @@ +package konkuk.thip.book.adapter.out.api.aladin; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class AladinApiClient { + + private final AladinApiUtil aladinApiUtil; + + public Integer findPageCountByIsbn(String isbn) { + return aladinApiUtil.getPageCount(isbn); + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiParam.java b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiParam.java new file mode 100644 index 000000000..a6c51c27a --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiParam.java @@ -0,0 +1,20 @@ +package konkuk.thip.book.adapter.out.api.aladin; + +public enum AladinApiParam { + + ITEM_ID_TYPE("ISBN"), + OUTPUT("js"), + API_VERSION("20131101"), + SUB_INFO_PARSING_KEY("subInfo"), + PAGE_COUNT_PARSING_KEY("itemPage"); + + private final String value; + + AladinApiParam(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java new file mode 100644 index 000000000..7a63ac018 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java @@ -0,0 +1,67 @@ +package konkuk.thip.book.adapter.out.api.aladin; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.common.exception.BusinessException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; + +import static konkuk.thip.book.adapter.out.api.aladin.AladinApiParam.*; +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_ALADIN_API_ISBN_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_ALADIN_API_PARSING_ERROR; + +@Component +@RequiredArgsConstructor +@Slf4j +public class AladinApiUtil { + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + @Value("${aladin.ttbKey}") + private String ttbKey; + + @Value("${aladin.baseUrl}") + private String baseUrl; + + + private String buildLookupUrl(String isbn) { + return String.format( + baseUrl + "ttbkey=%s&itemIdType=%s&itemId=%s&output=%s&Version=%s", + ttbKey, + ITEM_ID_TYPE.getValue(), + isbn, + OUTPUT.getValue(), + API_VERSION.getValue() + ); + } + + public Integer getPageCount(String isbn) { + String url = buildLookupUrl(isbn); + String response = restTemplate.getForObject(url, String.class); + + try { + JsonNode jsonNode = objectMapper.readTree(response); + JsonNode items = jsonNode.path("item"); + + // json 응답 결과에 item 키값이 없는 경우 + if (!items.isArray() || items.isEmpty()) { + // TODO : 알라딘으로부터 page 정보가 없으면 ?? + // 보상 시나리오 : 유저에게 "page 정보를 찾을 수 없는 책입니다. 직접 page 정보를 입력하세요" 라고 안내 + // 일단 지금은 exception throw 만 진행 + throw new BusinessException(BOOK_ALADIN_API_ISBN_NOT_FOUND); + } + + JsonNode subInfo = items.get(0).path(SUB_INFO_PARSING_KEY.getValue()); + + return subInfo.path(PAGE_COUNT_PARSING_KEY.getValue()).asInt(); + } catch (IOException e) { + throw new BusinessException(BOOK_ALADIN_API_PARSING_ERROR); + } + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java b/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java index 62c57cba0..6fe64a38a 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java +++ b/src/main/java/konkuk/thip/book/adapter/out/jpa/BookJpaEntity.java @@ -39,4 +39,7 @@ public class BookJpaEntity extends BaseJpaEntity { @Column(length = 1000) private String description; -} \ No newline at end of file + public void changePageCount(Integer pageCount) { + this.pageCount = pageCount; + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java index 34f572296..8605655e5 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookCommandPersistenceAdapter.java @@ -39,4 +39,14 @@ public Book findById(Long id) { return bookMapper.toDomainEntity(bookJpaEntity); } + + @Override + public void updateForPageCount(Book book) { + BookJpaEntity bookJpaEntity = bookJpaRepository.findById(book.getId()).orElseThrow( + () -> new EntityNotFoundException(BOOK_NOT_FOUND) + ); + + bookJpaEntity.changePageCount(book.getPageCount()); + bookJpaRepository.save(bookJpaEntity); + } } diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java index 145dee188..803f57b82 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java @@ -2,8 +2,15 @@ import konkuk.thip.book.adapter.out.api.dto.NaverBookParseResult; import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; +import konkuk.thip.book.domain.Book; public interface BookApiQueryPort { + NaverBookParseResult findBooksByKeyword(String keyword, int start); + NaverDetailBookParseResult findDetailBookByIsbn(String isbn); + + Integer findPageCountByIsbn(String isbn); + + Book loadBookWithPageByIsbn(String isbn); } diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java index a1c83f9d8..c7ce10e8e 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookCommandPort.java @@ -10,4 +10,6 @@ public interface BookCommandPort { Long save(Book book); Book findById(Long id); -} \ No newline at end of file + + void updateForPageCount(Book book); +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java index 4872f1674..9b41d751c 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java @@ -56,7 +56,8 @@ public BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userI naverResult.description()); Long newBookId = bookCommandPort.save(newBook); - book = newBook.withId(newBookId); + + book = bookCommandPort.findById(newBookId); } // 유저가 저장한 책 목록 조회 diff --git a/src/main/java/konkuk/thip/book/domain/Book.java b/src/main/java/konkuk/thip/book/domain/Book.java index 3b0e53723..7d5a2b785 100644 --- a/src/main/java/konkuk/thip/book/domain/Book.java +++ b/src/main/java/konkuk/thip/book/domain/Book.java @@ -55,19 +55,11 @@ public static Book withoutId(String title, String isbn, String authorName, boole .build(); } - public Book withId(Long id) { - return Book.builder() - .id(id) - .title(this.title) - .isbn(this.isbn) - .authorName(this.authorName) - .bestSeller(this.bestSeller) - .publisher(this.publisher) - .imageUrl(this.imageUrl) - .pageCount(this.pageCount) - .description(this.description) - .build(); + public boolean hasPageCount() { + return pageCount != null && pageCount > 0; } - + public void changePageCount(Integer newPageCount) { + this.pageCount = newPageCount; + } } 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 289a5d5ce..03df7c30e 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -42,30 +42,32 @@ public enum ErrorCode implements ResponseCode { */ BOOK_KEYWORD_ENCODING_FAILED(HttpStatus.BAD_REQUEST, 80000, "검색어 인코딩에 실패했습니다."), BOOK_NAVER_API_REQUEST_ERROR(HttpStatus.BAD_REQUEST, 80001,"네이버 API 요청에 실패하였습니다."), - BOOK_NAVER_API_PARSING_ERROR(HttpStatus.BAD_REQUEST, 80002,"네이버 API 응답 파싱에 실패하였습니다."), + BOOK_NAVER_API_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80002,"네이버 API 응답 파싱에 실패하였습니다."), BOOK_NAVER_API_URL_ERROR(HttpStatus.BAD_REQUEST, 80003,"네이버 API URL이 잘못되었습니다."), BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED(HttpStatus.BAD_REQUEST, 80004,"네이버 API 요청 중, HTTP 연결에 실패하였습니다."), BOOK_NAVER_API_RESPONSE_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80005,"네이버 API 응답에 실패하였습니다."), BOOK_SEARCH_PAGE_OUT_OF_RANGE(HttpStatus.BAD_REQUEST, 80006,"검색어 페이지가 범위를 벗어났습니다."), BOOK_KEYWORD_REQUIRED(HttpStatus.BAD_REQUEST, 80007, "검색어는 필수 입력값입니다."), BOOK_PAGE_NUMBER_INVALID(HttpStatus.BAD_REQUEST, 80008, "페이지 번호는 1 이상의 값이어야 합니다."), - BOOK_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80009, "ISBN으로 검색한 결과가 존재하지 않습니다."), + BOOK_NAVER_API_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80009, "네이버 API 에서 ISBN으로 검색한 결과가 존재하지 않습니다."), BOOK_NOT_FOUND(HttpStatus.BAD_REQUEST, 80010, "존재하지 않는 BOOK 입니다."), BOOK_ALREADY_SAVED(HttpStatus.BAD_REQUEST, 80011, "사용자가 이미 저장한 책입니다."), DUPLICATED_BOOKS_IN_COLLECTION(HttpStatus.INTERNAL_SERVER_ERROR, 80012, "중복된 책이 존재합니다."), BOOK_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80013, "사용자가 저장하지 않은 책은 저장삭제 할 수 없습니다."), BOOK_NOT_SAVED_DB_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80014, "DB에 존재하지 않은 책은 저장삭제 할 수 없습니다."), + BOOK_ALADIN_API_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80015, "알라딘 API 응답 파싱에 실패하였습니다."), + BOOK_ALADIN_API_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80016, "알라딘 API 에서 ISBN으로 검색한 결과가 존재하지 않습니다."), /** * 90000 : recentSearch error */ INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 900000,"알맞은 검색어 타입을 찾을 수 없습니다."), - /** * 100000 : room error */ ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 100000, "존재하지 않는 ROOM 입니다."), + INVALID_ROOM_CREATE(HttpStatus.BAD_REQUEST, 100001, "유효하지 않은 ROOM 생성 요청 입니다."), /** * 110000 : vote error @@ -87,6 +89,11 @@ public enum ErrorCode implements ResponseCode { */ USER_ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 130000, "존재하지 않는 USER_ROOM (방과 사용자 관계) 입니다."), + /** + * 140000 : Category error + */ + CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, 140000, "존재하지 않는 CATEGORY 입니다.") + ; private final HttpStatus httpStatus; diff --git a/src/main/java/konkuk/thip/config/RestTemplateConfig.java b/src/main/java/konkuk/thip/config/RestTemplateConfig.java new file mode 100644 index 000000000..11b10d449 --- /dev/null +++ b/src/main/java/konkuk/thip/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package konkuk.thip.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java index 90ffc6982..acfd5076f 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomCommandController.java @@ -1,10 +1,26 @@ package konkuk.thip.room.adapter.in.web; +import jakarta.validation.Valid; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.room.adapter.in.web.request.RoomCreateRequest; +import konkuk.thip.room.adapter.in.web.response.RoomCreateResponse; +import konkuk.thip.room.application.port.in.RoomCreateUseCase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController @RequiredArgsConstructor public class RoomCommandController { + private final RoomCreateUseCase roomCreateUseCase; + + @PostMapping("/rooms") + public BaseResponse createRoom(@Valid @RequestBody RoomCreateRequest request, @UserId Long userId) { + return BaseResponse.ok(RoomCreateResponse.of( + roomCreateUseCase.createRoom(request.toCommand(), userId) + )); + } } diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/DummyRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/DummyRequest.java deleted file mode 100644 index 2e023b5fa..000000000 --- a/src/main/java/konkuk/thip/room/adapter/in/web/request/DummyRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.room.adapter.in.web.request; - -import lombok.Getter; - -@Getter -public class DummyRequest { -} diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java new file mode 100644 index 000000000..fb025c30f --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/request/RoomCreateRequest.java @@ -0,0 +1,60 @@ +package konkuk.thip.room.adapter.in.web.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.*; +import konkuk.thip.room.application.port.in.dto.RoomCreateCommand; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +public record RoomCreateRequest( + @NotBlank(message = "ISBN은 필수입니다.") + String isbn, + + @NotBlank(message = "카테고리는 필수입니다.") + String category, + + @NotBlank(message = "방 이름은 필수입니다.") + String roomName, + + @NotBlank(message = "설명은 필수입니다.") + String description, + + @Pattern( + regexp = "\\d{4}\\.\\d{2}\\.\\d{2}", + message = "진행 시작일은 yyyy.MM.dd 형식이어야 합니다." + ) + String progressStartDate, + + @Pattern( + regexp = "\\d{4}\\.\\d{2}\\.\\d{2}", + message = "진행 종료일은 yyyy.MM.dd 형식이어야 합니다." + ) + String progressEndDate, + + @Min(value = 1, message = "모집 인원은 최소 1명이어야 합니다.") + @Max(value = 30, message = "모집 인원은 최대 30명이어야 합니다.") + int recruitCount, + + @Nullable + @Pattern(regexp = "\\d{4}", message = "비밀번호는 숫자 4자리여야 합니다.") + String password, + + @NotNull(message = "방 공개 설정 여부는 필수입니다.") + Boolean isPublic +) { + public RoomCreateCommand toCommand() { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + return new RoomCreateCommand( + isbn, + category, + roomName, + description, + LocalDate.parse(progressStartDate, formatter), + LocalDate.parse(progressEndDate, formatter), + recruitCount, + password, + isPublic + ); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/DummyResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/DummyResponse.java deleted file mode 100644 index 59f496684..000000000 --- a/src/main/java/konkuk/thip/room/adapter/in/web/response/DummyResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.room.adapter.in.web.response; - -import lombok.Getter; - -@Getter -public class DummyResponse { -} diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomCreateResponse.java b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomCreateResponse.java new file mode 100644 index 000000000..12489b279 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/in/web/response/RoomCreateResponse.java @@ -0,0 +1,7 @@ +package konkuk.thip.room.adapter.in.web.response; + +public record RoomCreateResponse(Long roomId) { + public static RoomCreateResponse of(Long roomId) { + return new RoomCreateResponse(roomId); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java index ce12c70fe..5426f28e2 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java +++ b/src/main/java/konkuk/thip/room/adapter/out/jpa/RoomJpaEntity.java @@ -29,7 +29,7 @@ public class RoomJpaEntity extends BaseJpaEntity { private boolean isPublic; @Column(length = 4) - private Integer password; + private String password; @Builder.Default @Column(name = "room_percentage",nullable = false) diff --git a/src/main/java/konkuk/thip/room/adapter/out/mapper/RoomMapper.java b/src/main/java/konkuk/thip/room/adapter/out/mapper/RoomMapper.java index e28e2d573..ecca64cc9 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/mapper/RoomMapper.java +++ b/src/main/java/konkuk/thip/room/adapter/out/mapper/RoomMapper.java @@ -9,12 +9,12 @@ @Component public class RoomMapper { - public RoomJpaEntity roomJpaEntity(Room room, BookJpaEntity bookJpaEntity, CategoryJpaEntity categoryJpaEntity) { + public RoomJpaEntity toJpaEntity(Room room, BookJpaEntity bookJpaEntity, CategoryJpaEntity categoryJpaEntity) { return RoomJpaEntity.builder() .title(room.getTitle()) .description(room.getDescription()) .isPublic(room.isPublic()) - .password(room.getPassword()) + .password(room.getHashedPassword()) .roomPercentage(room.getRoomPercentage()) .startDate(room.getStartDate()) .endDate(room.getEndDate()) @@ -30,7 +30,7 @@ public Room toDomainEntity(RoomJpaEntity roomJpaEntity) { .title(roomJpaEntity.getTitle()) .description(roomJpaEntity.getDescription()) .isPublic(roomJpaEntity.isPublic()) - .password(roomJpaEntity.getPassword()) + .hashedPassword(roomJpaEntity.getPassword()) .roomPercentage(roomJpaEntity.getRoomPercentage()) .startDate(roomJpaEntity.getStartDate()) .endDate(roomJpaEntity.getEndDate()) diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryCommandPersistenceAdapter.java new file mode 100644 index 000000000..ae2e25aa6 --- /dev/null +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryCommandPersistenceAdapter.java @@ -0,0 +1,28 @@ +package konkuk.thip.room.adapter.out.persistence; + +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; +import konkuk.thip.room.adapter.out.mapper.CategoryMapper; +import konkuk.thip.room.application.port.out.CategoryCommandPort; +import konkuk.thip.room.domain.Category; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import static konkuk.thip.common.exception.code.ErrorCode.CATEGORY_NOT_FOUND; + +@Repository +@RequiredArgsConstructor +public class CategoryCommandPersistenceAdapter implements CategoryCommandPort { + + private final CategoryJpaRepository categoryJpaRepository; + private final CategoryMapper categoryMapper; + + @Override + public Category findByValue(String value) { + CategoryJpaEntity categoryJpaEntity = categoryJpaRepository.findByValue(value).orElseThrow( + () -> new EntityNotFoundException(CATEGORY_NOT_FOUND) + ); + + return categoryMapper.toDomainEntity(categoryJpaEntity); + } +} diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryJpaRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryJpaRepository.java index 2cc722535..a8c9c04ef 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryJpaRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/CategoryJpaRepository.java @@ -3,5 +3,9 @@ import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + public interface CategoryJpaRepository extends JpaRepository { + + Optional findByValue(String value); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java index c6a607187..e86795d08 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomCommandPersistenceAdapter.java @@ -1,6 +1,9 @@ package konkuk.thip.room.adapter.out.persistence; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.BookJpaRepository; import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.room.adapter.out.jpa.CategoryJpaEntity; import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.mapper.RoomMapper; import konkuk.thip.room.application.port.out.RoomCommandPort; @@ -8,13 +11,16 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import static konkuk.thip.common.exception.code.ErrorCode.ROOM_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.*; @Repository @RequiredArgsConstructor public class RoomCommandPersistenceAdapter implements RoomCommandPort { private final RoomJpaRepository roomJpaRepository; + private final BookJpaRepository bookJpaRepository; + private final CategoryJpaRepository categoryJpaRepository; + private final RoomMapper roomMapper; @Override @@ -25,4 +31,18 @@ public Room findById(Long id) { return roomMapper.toDomainEntity(roomJpaEntity); } + + @Override + public Long save(Room room) { + BookJpaEntity bookJpaEntity = bookJpaRepository.findById(room.getBookId()).orElseThrow( + () -> new EntityNotFoundException(BOOK_NOT_FOUND) + ); + + CategoryJpaEntity categoryJpaEntity = categoryJpaRepository.findById(room.getCategoryId()).orElseThrow( + () -> new EntityNotFoundException(CATEGORY_NOT_FOUND) + ); + + RoomJpaEntity roomJpaEntity = roomMapper.toJpaEntity(room, bookJpaEntity, categoryJpaEntity); + return roomJpaRepository.save(roomJpaEntity).getRoomId(); + } } diff --git a/src/main/java/konkuk/thip/room/application/port/in/DummyUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/DummyUseCase.java deleted file mode 100644 index 62b144647..000000000 --- a/src/main/java/konkuk/thip/room/application/port/in/DummyUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package konkuk.thip.room.application.port.in; - -public interface DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomCreateUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomCreateUseCase.java new file mode 100644 index 000000000..b53e36b28 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomCreateUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.in; + +import konkuk.thip.room.application.port.in.dto.RoomCreateCommand; + +public interface RoomCreateUseCase { + + Long createRoom(RoomCreateCommand command, Long userId); +} diff --git a/src/main/java/konkuk/thip/room/application/port/in/dto/DummyCommand.java b/src/main/java/konkuk/thip/room/application/port/in/dto/DummyCommand.java deleted file mode 100644 index b8ad36e4e..000000000 --- a/src/main/java/konkuk/thip/room/application/port/in/dto/DummyCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package konkuk.thip.room.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyCommand { - -} diff --git a/src/main/java/konkuk/thip/room/application/port/in/dto/RoomCreateCommand.java b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomCreateCommand.java new file mode 100644 index 000000000..874071f9c --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/in/dto/RoomCreateCommand.java @@ -0,0 +1,24 @@ +package konkuk.thip.room.application.port.in.dto; + +import java.time.LocalDate; + +public record RoomCreateCommand( + String isbn, + + String category, + + String roomName, + + String description, + + LocalDate progressStartDate, + + LocalDate progressEndDate, + + int recruitCount, + + String password, + + Boolean isPublic +) { +} diff --git a/src/main/java/konkuk/thip/room/application/port/out/CategoryCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/CategoryCommandPort.java new file mode 100644 index 000000000..5aa68ff5a --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/port/out/CategoryCommandPort.java @@ -0,0 +1,8 @@ +package konkuk.thip.room.application.port.out; + +import konkuk.thip.room.domain.Category; + +public interface CategoryCommandPort { + + Category findByValue(String value); +} diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java index aa4ca8d28..57cdafd88 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomCommandPort.java @@ -5,4 +5,6 @@ public interface RoomCommandPort { Room findById(Long id); + + Long save(Room room); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomCreateService.java b/src/main/java/konkuk/thip/room/application/service/RoomCreateService.java new file mode 100644 index 000000000..dfd5b68a1 --- /dev/null +++ b/src/main/java/konkuk/thip/room/application/service/RoomCreateService.java @@ -0,0 +1,76 @@ +package konkuk.thip.room.application.service; + +import konkuk.thip.book.application.port.out.BookApiQueryPort; +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.room.application.port.in.RoomCreateUseCase; +import konkuk.thip.room.application.port.in.dto.RoomCreateCommand; +import konkuk.thip.room.application.port.out.CategoryCommandPort; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.domain.Category; +import konkuk.thip.room.domain.Room; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RoomCreateService implements RoomCreateUseCase { + + private final RoomCommandPort roomCommandPort; + private final CategoryCommandPort categoryCommandPort; + private final BookCommandPort bookCommandPort; + private final BookApiQueryPort bookApiQueryPort; + + @Override + @Transactional + public Long createRoom(RoomCreateCommand command, Long userId) { + // 1. Category 찾기 + Category category = categoryCommandPort.findByValue(command.category()); + + // 2. Book 찾기, 없으면 Book 로드 및 저장 + Long bookId = resolveBookAndEnsurePage(command.isbn()); + + // 3. Room 생성 및 저장 + Room room = Room.withoutId( + command.roomName(), + command.description(), + command.isPublic(), + command.password(), + command.progressStartDate(), + command.progressEndDate(), + command.recruitCount(), + bookId, + category.getId() + ); + + // TODO : 방 생성한 사람 (= api 호출 토큰에 포함된 userId) 이 해당 방에 속한 멤버라는 사실을 DB에 영속화 해야함 + // UserRoom 도메인이 정리되면 개발 ㄱㄱ + + return roomCommandPort.save(room); + } + + private Long resolveBookAndEnsurePage(String isbn) { + try { + Book existing = bookCommandPort.findByIsbn(isbn); + if (!existing.hasPageCount()) { + updateBookPageCount(existing); + } + return existing.getId(); + } catch (EntityNotFoundException e) { + return saveNewBookWithPageCount(isbn); + } + } + + private void updateBookPageCount(Book book) { + Integer pageCount = bookApiQueryPort.findPageCountByIsbn(book.getIsbn()); + book.changePageCount(pageCount); + bookCommandPort.updateForPageCount(book); + } + + private Long saveNewBookWithPageCount(String isbn) { + Book loaded = bookApiQueryPort.loadBookWithPageByIsbn(isbn); + return bookCommandPort.save(loaded); + } +} diff --git a/src/main/java/konkuk/thip/room/application/service/RoomService.java b/src/main/java/konkuk/thip/room/application/service/RoomService.java deleted file mode 100644 index d0621a5c5..000000000 --- a/src/main/java/konkuk/thip/room/application/service/RoomService.java +++ /dev/null @@ -1,11 +0,0 @@ -package konkuk.thip.room.application.service; - -import konkuk.thip.room.application.port.in.DummyUseCase; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class RoomService implements DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/room/domain/Room.java b/src/main/java/konkuk/thip/room/domain/Room.java index f4bb359b1..d630ce4b4 100644 --- a/src/main/java/konkuk/thip/room/domain/Room.java +++ b/src/main/java/konkuk/thip/room/domain/Room.java @@ -1,16 +1,23 @@ package konkuk.thip.room.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.common.entity.StatusType; import lombok.Getter; import lombok.experimental.SuperBuilder; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import java.time.LocalDate; +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_ROOM_CREATE; + @Getter @SuperBuilder public class Room extends BaseDomainEntity { + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + private Long id; private String title; @@ -19,7 +26,7 @@ public class Room extends BaseDomainEntity { private boolean isPublic; - private Integer password; + private String hashedPassword; private double roomPercentage; @@ -33,6 +40,63 @@ public class Room extends BaseDomainEntity { private Long categoryId; + public static Room withoutId(String title, String description, boolean isPublic, String password, LocalDate startDate, LocalDate endDate, int recruitCount, Long bookId, Long categoryId) { + validateVisibilityPasswordRule(isPublic, password); + validateDates(startDate, endDate); + + // 비밀번호 해싱 + String hashedPassword = (password != null) ? PASSWORD_ENCODER.encode(password) : null; + + return Room.builder() + .id(null) + .title(title) + .description(description) + .isPublic(isPublic) + .hashedPassword(hashedPassword) + .roomPercentage(0) // 처음 Room 생성 시 -> 0% + .startDate(startDate) + .endDate(endDate) + .recruitCount(recruitCount) + .bookId(bookId) + .categoryId(categoryId) + .build(); + } + + private static void validateVisibilityPasswordRule(boolean isPublic, String password) { + boolean hasPassword = password != null; + + if ((isPublic && hasPassword) || (!isPublic && !hasPassword)) { + String message = String.format( + "방 공개/비공개 여부와 비밀번호 설정이 일치하지 않습니다. 공개 여부 = %s, 비밀번호 존재 여부 = %s", + isPublic, hasPassword + ); + throw new InvalidStateException(INVALID_ROOM_CREATE, + new IllegalArgumentException(message)); + } + } + + private static void validateDates(LocalDate startDate, LocalDate endDate) { + LocalDate today = LocalDate.now(); + + if (!startDate.isBefore(endDate)) { + String message = String.format( + "시작일(%s)은 종료일(%s)보다 이전이어야 합니다.", + startDate, endDate + ); + throw new InvalidStateException(INVALID_ROOM_CREATE, + new IllegalArgumentException(message)); + } + + if (!startDate.isAfter(today)) { + String message = String.format( + "시작일(%s)은 현재 날짜(%s) 이후여야 합니다.", // 현재 날짜 미포함 + startDate, today + ); + throw new InvalidStateException(INVALID_ROOM_CREATE, + new IllegalArgumentException(message)); + } + } + public boolean isExpired() { return this.getStatus() == StatusType.EXPIRED; } @@ -40,4 +104,11 @@ public boolean isExpired() { public void updateRoomPercentage(double roomPercentage) { this.roomPercentage = roomPercentage; } + + public boolean matchesPassword(String rawPassword) { + if (this.hashedPassword == null || rawPassword == null) { + return false; + } + return PASSWORD_ENCODER.matches(rawPassword, this.hashedPassword); + } } diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java new file mode 100644 index 000000000..6e9623cc7 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateAPITest.java @@ -0,0 +1,282 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +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.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; +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.http.MediaType; +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.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +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 RoomCreateAPITest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private CategoryJpaRepository categoryJpaRepository; + + @Autowired + private BookJpaRepository bookJpaRepository; + + @Autowired + private RoomJpaRepository roomJpaRepository; + + @AfterEach + void tearDown() { + roomJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + private void saveUserAndCategory() { + AliasJpaEntity alias = aliasJpaRepository.save(AliasJpaEntity.builder() + .value("책벌레") + .color("blue") + .imageUrl("http://image.url") + .build()); + + userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_432708231") + .nickname("User1") + .imageUrl("https://avatar1.jpg") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + categoryJpaRepository.save(CategoryJpaEntity.builder() + .value("소설") + .aliasForCategoryJpaEntity(alias) + .build()); + } + + private void saveBookWithPageCount() { + bookJpaRepository.save(BookJpaEntity.builder() + .title("작별하지 않는다") + .isbn("9788954682152") // 실제 isbn 값 + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(332) // pageCount 값이 null이 아닌 책 + .description("한강의 소설") + .build()); + } + + private void saveBookWithoutPageCount() { + bookJpaRepository.save(BookJpaEntity.builder() + .title("작별하지 않는다") + .isbn("9788954682152") // 실제 isbn 값 + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(null) // pageCount 값이 null 인 책 -> 실제 페이지 정보 332 + .description("한강의 소설") + .build()); + } + + private Map buildRoomCreateRequest() { + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("category", "소설"); + request.put("roomName", "방이름"); + request.put("description", "방설명"); + request.put("progressStartDate", "2025.07.10"); + request.put("progressEndDate", "2025.08.10"); + request.put("recruitCount", 3); + request.put("password", null); + request.put("isPublic", true); + return request; + } + + @Test + @DisplayName("isbn 에 해당하는 책(with pageCount)이 DB에 존재할 때, 해당 책과 연관된 방을 생성할 수 있다.") + void room_create_book_with_page_exist() throws Exception { + //given : user, category, pageCount값이 있는 book 생성, request 생성 + saveUserAndCategory(); + saveBookWithPageCount(); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + Long bookId = bookJpaRepository.findAll().get(0).getBookId(); + Long categoryId = categoryJpaRepository.findAll().get(0).getCategoryId(); + + Map request = buildRoomCreateRequest(); + + //when + ResultActions result = mockMvc.perform(post("/rooms") + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + )); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + Long roomId = jsonNode.path("data").path("roomId").asLong(); + + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(roomId).orElse(null); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + LocalDate startDate = LocalDate.parse((String) request.get("progressStartDate"), formatter); + LocalDate endDate = LocalDate.parse((String) request.get("progressEndDate"), formatter); + + assertThat(roomJpaEntity).isNotNull() + .extracting( + "title", "description", "public", "password", "roomPercentage", + "startDate", "endDate", "recruitCount", "bookJpaEntity.bookId", "categoryJpaEntity.categoryId" + ) + .containsExactly( + request.get("roomName"), request.get("description"), request.get("isPublic"), request.get("password"), 0.0, + startDate, endDate, request.get("recruitCount"), bookId, categoryId + ); + } + + @Test + @DisplayName("isbn 에 해당하는 책(without pageCount)이 DB에 존재할 때, 해당 책의 page 정보를 update 한 후 연관된 방을 생성할 수 있다.") + void room_create_book_without_page_exist() throws Exception { + //given : user, category, pageCount값이 없는 book 생성, request 생성 + saveUserAndCategory(); + saveBookWithoutPageCount(); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + Long bookId = bookJpaRepository.findAll().get(0).getBookId(); + Long categoryId = categoryJpaRepository.findAll().get(0).getCategoryId(); + + Map request = buildRoomCreateRequest(); + + //when + ResultActions result = mockMvc.perform(post("/rooms") + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + )); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + Long roomId = jsonNode.path("data").path("roomId").asLong(); + + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(roomId).orElse(null); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + LocalDate startDate = LocalDate.parse((String) request.get("progressStartDate"), formatter); + LocalDate endDate = LocalDate.parse((String) request.get("progressEndDate"), formatter); + + assertThat(roomJpaEntity).isNotNull() + .extracting( + "title", "description", "public", "password", "roomPercentage", + "startDate", "endDate", "recruitCount", "bookJpaEntity.bookId", "categoryJpaEntity.categoryId" + ) + .containsExactly( + request.get("roomName"), request.get("description"), request.get("isPublic"), request.get("password"), 0.0, + startDate, endDate, request.get("recruitCount"), bookId, categoryId + ); + + // update 된 책 검증 + BookJpaEntity updatedBookJpaEntity = bookJpaRepository.findById(bookId).orElse(null); + assertThat(updatedBookJpaEntity.getPageCount()).isEqualTo(332); + } + + @Test + @DisplayName("isbn 에 해당하는 책이 존재하지 않을 경우, page 정보를 포함하는 책을 save 한 후 연관된 방을 생성할 수 있다.") + void room_create_book_not_exist() throws Exception { + //given : user, category 생성, request 생성 (book 생성 X) + saveUserAndCategory(); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + Long categoryId = categoryJpaRepository.findAll().get(0).getCategoryId(); + + Map request = buildRoomCreateRequest(); + + //when + ResultActions result = mockMvc.perform(post("/rooms") + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + )); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.roomId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + Long roomId = jsonNode.path("data").path("roomId").asLong(); + + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(roomId).orElse(null); + + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + LocalDate startDate = LocalDate.parse((String) request.get("progressStartDate"), formatter); + LocalDate endDate = LocalDate.parse((String) request.get("progressEndDate"), formatter); + + assertThat(roomJpaEntity).isNotNull() + .extracting( + "title", "description", "public", "password", "roomPercentage", + "startDate", "endDate", "recruitCount", "categoryJpaEntity.categoryId" + ) + .containsExactly( + request.get("roomName"), request.get("description"), request.get("isPublic"), request.get("password"), 0.0, + startDate, endDate, request.get("recruitCount"), categoryId + ); + + // 새로 DB에 저장된 책 검증 + BookJpaEntity newSavedBookJpaEntity = bookJpaRepository.findAll().get(0); + assertThat(newSavedBookJpaEntity) + .extracting( + "isbn", "authorName", "pageCount" + ) + .containsExactly( + "9788954682152", "한강", 332 + ); + } + +} diff --git a/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateControllerTest.java b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateControllerTest.java new file mode 100644 index 000000000..cad63a039 --- /dev/null +++ b/src/test/java/konkuk/thip/room/adapter/in/web/RoomCreateControllerTest.java @@ -0,0 +1,197 @@ +package konkuk.thip.room.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.HashMap; +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("방 생성 api controller 단위 테스트") +class RoomCreateControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + + private Map buildValidRequest() { + Map request = new HashMap<>(); + request.put("isbn", "9788954682152"); + request.put("category", "소설"); + request.put("roomName", "방이름"); + request.put("description", "방설명"); + request.put("progressStartDate", "2025.07.10"); + request.put("progressEndDate", "2025.08.10"); + request.put("recruitCount", 3); + request.put("password", null); + request.put("isPublic", true); + return request; + } + + private void assertBad(Map req, String msg) throws Exception { + mockMvc.perform(post("/rooms") + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(req))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString(msg))); + } + + @Nested + @DisplayName("ISBN 검증") + class IsbnValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_isbn() throws Exception { + Map req = buildValidRequest(); + req.put("isbn", ""); + assertBad(req, "ISBN은 필수입니다."); + } + } + + @Nested + @DisplayName("Category 검증") + class CategoryValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_category() throws Exception { + Map req = buildValidRequest(); + req.put("category", ""); + assertBad(req, "카테고리는 필수입니다."); + } + } + + @Nested + @DisplayName("RoomName 검증") + class RoomNameValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_room_name() throws Exception { + Map req = buildValidRequest(); + req.put("roomName", ""); + assertBad(req, "방 이름은 필수입니다."); + } + } + + @Nested + @DisplayName("Description 검증") + class DescriptionValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_description() throws Exception { + Map req = buildValidRequest(); + req.put("description", ""); + assertBad(req, "설명은 필수입니다."); + } + } + + @Nested + @DisplayName("StartDate 검증") + class StartDateValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_start_date() throws Exception { + Map req = buildValidRequest(); + req.put("progressStartDate", ""); + assertBad(req, "진행 시작일은 yyyy.MM.dd 형식이어야 합니다."); + } + @Test + @DisplayName("형식 벗어날 때 400 error") + void pattern_start_date() throws Exception { + Map req = buildValidRequest(); + req.put("progressStartDate", "2025-07-10"); + assertBad(req, "진행 시작일은 yyyy.MM.dd 형식이어야 합니다."); + } + } + + @Nested + @DisplayName("EndDate 검증") + class EndDateValidation { + @Test + @DisplayName("빈 문자열일 때 400 error") + void blank_end_date() throws Exception { + Map req = buildValidRequest(); + req.put("progressEndDate", ""); + assertBad(req, "진행 종료일은 yyyy.MM.dd 형식이어야 합니다."); + } + @Test + @DisplayName("형식 벗어날 때 400 error") + void pattern_end_date() throws Exception { + Map req = buildValidRequest(); + req.put("progressEndDate", "2025/08/10"); + assertBad(req, "진행 종료일은 yyyy.MM.dd 형식이어야 합니다."); + } + } + + @Nested + @DisplayName("RecruitCount 검증") + class RecruitCountValidation { + @Test + @DisplayName("1 미만일 때 400 error") + void less_than_one() throws Exception { + Map req = buildValidRequest(); + req.put("recruitCount", 0); + assertBad(req, "모집 인원은 최소 1명이어야 합니다."); + } + + @Test + @DisplayName("30 초과일 때 400 error") + void greater_than_max() throws Exception { + Map req = buildValidRequest(); + req.put("recruitCount", 31); + assertBad(req, "모집 인원은 최대 30명이어야 합니다."); + } + } + + @Nested + @DisplayName("Password 검증") + class PasswordValidation { + @Test + @DisplayName("숫자로 구성되지 않았을 때 400 error") + void invalid_password() throws Exception { + Map req = buildValidRequest(); + req.put("password", "12ab"); + assertBad(req, "비밀번호는 숫자 4자리여야 합니다."); + } + + @Test + @DisplayName("4자리 숫자 아닐 때 400 error") + void short_password() throws Exception { + Map req = buildValidRequest(); + req.put("password", "123"); + assertBad(req, "비밀번호는 숫자 4자리여야 합니다."); + } + } + + @Nested + @DisplayName("isPublic 검증") + class IsPublicValidation { + @Test + @DisplayName("값이 없을 때 400 error") + void missing_is_public() throws Exception { + Map req = buildValidRequest(); + req.put("isPublic", null); + assertBad(req, "방 공개 설정 여부는 필수입니다."); + } + } +} diff --git a/src/test/java/konkuk/thip/room/domain/RoomTest.java b/src/test/java/konkuk/thip/room/domain/RoomTest.java new file mode 100644 index 000000000..fd11dd10e --- /dev/null +++ b/src/test/java/konkuk/thip/room/domain/RoomTest.java @@ -0,0 +1,155 @@ +package konkuk.thip.room.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDate; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Room 단위 테스트") +class RoomTest { + + private static final PasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder(); + + private final LocalDate today = LocalDate.now(); + private final LocalDate START = today.plusDays(1); + private final LocalDate END = today.plusDays(32); + + @Test + @DisplayName("withoutId: 공개 방이면서 password가 not null 이면, InvalidStateException 발생한다.") + void withoutId_public_password_not_null() { + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> + Room.withoutId( + "제목", "설명", true, "1234", + START, END, 5, 123L, 456L + ) + ); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("방 공개/비공개 여부와 비밀번호 설정이 일치하지 않습니다.")); + assertTrue(ex.getCause().getMessage().contains("공개 여부 = true, 비밀번호 존재 여부 = true")); + } + + @Test + @DisplayName("withoutId: 비공개 방이면서 password가 null 이면, InvalidStateException 발생한다.") + void withoutId_private_password_null() { + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> + Room.withoutId( + "제목", "설명", false, null, + START, END, 5, 123L, 456L + ) + ); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("방 공개/비공개 여부와 비밀번호 설정이 일치하지 않습니다.")); + assertTrue(ex.getCause().getMessage().contains("공개 여부 = false, 비밀번호 존재 여부 = false")); + } + + @Test + @DisplayName("withoutId: 시간 순서상 시작일 -> 종료일이 아닐 경우, InvalidStateException 발생한다.") + void withoutId_startDate_not_before_endDate() { + LocalDate start = LocalDate.of(2025, 8, 1); + LocalDate end = LocalDate.of(2025, 7, 1); + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> + Room.withoutId( + "제목", "설명", false, "1234", + start, end, 5, 123L, 456L + ) + ); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains( + String.format("시작일(%s)은 종료일(%s)보다 이전이어야 합니다.", start, end) + )); + } + + @Test + @DisplayName("withoutId: 시작일이 현재 날짜보다 이전일 경우, InvalidStateException 발생한다.") + void withoutId_startDate_before_today() { + LocalDate today = LocalDate.now(); + LocalDate past = today.minusDays(1); + LocalDate future = today.plusDays(1); + + // 시작일이 현재시점 - 1 일 + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> + Room.withoutId( + "제목", "설명", false, "1234", + past, future, 5, 123L, 456L + ) + ); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains( + String.format("시작일(%s)은 현재 날짜(%s) 이후여야 합니다.", past, today) + )); + } + + @Test + @DisplayName("withoutId: 시작일 현재 날짜와 동일할 경우, InvalidStateException 발생한다.") + void withoutId_startDate_is_today() { + LocalDate today = LocalDate.now(); + LocalDate future = today.plusDays(1); + + // 시작일 == 현재시점 + InvalidStateException ex = assertThrows(InvalidStateException.class, () -> + Room.withoutId( + "제목", "설명", false, "1234", + today, future, 5, 123L, 456L + ) + ); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains( + String.format("시작일(%s)은 현재 날짜(%s) 이후여야 합니다.", today, today) + )); + } + + @Test + @DisplayName("withoutId: 전달받은 비밀번호를 해싱해서 보관한다.") + void withoutId_password_hashing() { + String rawPassword = "1234"; + + Room room = Room.withoutId( + "제목", "설명", false, rawPassword, + START, END, 5, 123L, 456L + ); + + String hashed = room.getHashedPassword(); + + // 해시된 비밀번호가 null이 아니고 원문과 다름 + assertNotNull(hashed); + assertNotEquals(rawPassword, hashed); + + // matchesPassword를 통해 원문 검증 시 true + assertTrue(PASSWORD_ENCODER.matches(rawPassword, hashed)); + } + + @Test + @DisplayName("matchesPassword: 올바른 비밀번호면 true 반환") + void matchesPassword_correct_password() { + Room room = Room.withoutId( + "제목", "설명", false, "1234", + START, END, 5, 123L, 456L + ); + assertTrue(room.matchesPassword("1234")); + } + + @Test + @DisplayName("matchesPassword: 잘못된 비밀번호면 false 반환") + void matchesPassword_incorrect_password() { + Room room = Room.withoutId( + "제목", "설명", false, "1234", + START, END, 5, 123L, 456L + ); + assertFalse(room.matchesPassword("0000")); + } + + @Test + @DisplayName("matchesPassword: Room이 비밀번호가 설정되어 있지 않은 공개방일 경우, false 반환") + void matchesPassword_no_password() { + Room room = Room.withoutId( + "제목", "설명", true, null, + START, END, 5, 123L, 456L + ); + assertFalse(room.matchesPassword("0000")); + } +}