diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index 39043b900..ec5c7bd85 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -41,9 +41,15 @@ jobs: mkdir -p ${{ env.TEST_RESOURCE_PATH }} echo "${{ secrets.APPLICATION_YML_DEV }}" > ${{ env.RESOURCE_PATH }}/application.yml echo "${{ secrets.APPLICATION_YML_TEST }}" > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml + - name: ๐Ÿ‘๐Ÿป grant execute permission for gradlew run: chmod +x gradlew +# - name: ๐Ÿš€ Start Redis +# uses: supercharge/redis-github-action@1.7.0 +# with: +# redis-version: 7 + - name: ๐Ÿ˜ build with Gradle run: ./gradlew build \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java index 85674a085..9039a4fe2 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookCommandController.java @@ -1,10 +1,29 @@ package konkuk.thip.book.adapter.in.web; +import jakarta.validation.constraints.Pattern; +import konkuk.thip.book.adapter.in.web.request.PostBookIsSavedRequest; +import konkuk.thip.book.adapter.in.web.response.PostBookIsSavedResponse; +import konkuk.thip.book.application.port.in.BookSavedUseCase; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +@Validated @RestController @RequiredArgsConstructor public class BookCommandController { + private final BookSavedUseCase bookSavedUseCase; + + //์ฑ… ์ €์žฅ ์ƒํƒœ ๋ณ€๊ฒฝ + @PostMapping("/books/{isbn}/saved") + public BaseResponse changeSavedBook(@PathVariable("isbn") + @Pattern(regexp = "\\d{13}") final String isbn, + @RequestBody final PostBookIsSavedRequest postBookIsSavedRequest, + @UserId final Long userId) { + return BaseResponse.ok(PostBookIsSavedResponse.of(bookSavedUseCase.changeSavedBook(isbn,postBookIsSavedRequest.type(),userId))); + } + } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java b/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java new file mode 100644 index 000000000..ba7b7b9bf --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/request/PostBookIsSavedRequest.java @@ -0,0 +1,10 @@ +package konkuk.thip.book.adapter.in.web.request; + +import jakarta.validation.constraints.NotNull; + + +public record PostBookIsSavedRequest( + @NotNull(message = "type์€ ํ•„์ˆ˜์ž…๋‹ˆ๋‹ค.") + boolean type +) { +} diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java new file mode 100644 index 000000000..b72130503 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/PostBookIsSavedResponse.java @@ -0,0 +1,15 @@ +package konkuk.thip.book.adapter.in.web.response; + +import konkuk.thip.book.application.port.in.dto.BookDetailSearchResult; +import konkuk.thip.book.application.port.in.dto.BookIsSavedResult; +import lombok.Builder; + +@Builder +public record PostBookIsSavedResponse( + String isbn, + boolean isSaved +) { + public static PostBookIsSavedResponse of(BookIsSavedResult bookIsSavedResult) { + return new PostBookIsSavedResponse(bookIsSavedResult.isbn(),bookIsSavedResult.isSaved()); + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java b/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java index 73082331d..511455a40 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/dto/NaverDetailBookParseResult.java @@ -12,5 +12,4 @@ public record NaverDetailBookParseResult( String isbn, String description ) { - } \ No newline at end of file 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 0dc6015aa..d9f97b0c2 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 @@ -1,12 +1,14 @@ package konkuk.thip.book.adapter.out.persistence; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import konkuk.thip.book.adapter.out.mapper.BookMapper; import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.book.domain.Book; +import konkuk.thip.common.exception.EntityNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; -import java.util.Optional; +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NOT_FOUND; @Repository @RequiredArgsConstructor @@ -16,8 +18,16 @@ public class BookCommandPersistenceAdapter implements BookCommandPort { private final BookMapper bookMapper; @Override - public Optional findByIsbn(String isbn) { - return bookJpaRepository.findByIsbn(isbn) - .map(bookMapper::toDomainEntity); + public Book findByIsbn(String isbn) { + BookJpaEntity bookJpaEntity = bookJpaRepository.findByIsbn(isbn).orElseThrow( + () -> new EntityNotFoundException(BOOK_NOT_FOUND)); + return bookMapper.toDomainEntity(bookJpaEntity); + } + + + @Override + public Long save(Book book) { + BookJpaEntity bookJpaEntity = bookMapper.toJpaEntity(book); + return bookJpaRepository.save(bookJpaEntity).getBookId(); } } diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookSavedUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookSavedUseCase.java new file mode 100644 index 000000000..33e181673 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/BookSavedUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.book.application.port.in; + +import konkuk.thip.book.application.port.in.dto.BookIsSavedResult; + +public interface BookSavedUseCase { + BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userId); +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/BookIsSavedResult.java b/src/main/java/konkuk/thip/book/application/port/in/dto/BookIsSavedResult.java new file mode 100644 index 000000000..a40f2884f --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/dto/BookIsSavedResult.java @@ -0,0 +1,12 @@ +package konkuk.thip.book.application.port.in.dto; + + +public record BookIsSavedResult( + String isbn, + boolean isSaved +) +{ + public static BookIsSavedResult of( String isbn,boolean isSaved) { + return new BookIsSavedResult(isbn, isSaved); + } +} 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 3e70212bd..649b2bf8e 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 @@ -7,6 +7,6 @@ public interface BookCommandPort { - Optional findByIsbn(String isbn); - + Book findByIsbn(String isbn); + Long save(Book book); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java new file mode 100644 index 000000000..258b7a76f --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java @@ -0,0 +1,78 @@ +package konkuk.thip.book.application.service; + +import jakarta.transaction.Transactional; +import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; +import konkuk.thip.book.application.port.in.BookSavedUseCase; +import konkuk.thip.book.application.port.in.dto.BookIsSavedResult; +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.BusinessException; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.saved.application.port.out.SavedCommandPort; +import konkuk.thip.saved.application.port.out.SavedQueryPort; +import konkuk.thip.book.domain.SavedBooks; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static konkuk.thip.common.exception.code.ErrorCode.BOOK_NOT_SAVED_DB_CANNOT_DELETE; + +@Service +@RequiredArgsConstructor +public class BookSavedService implements BookSavedUseCase { + + private final BookApiQueryPort bookApiQueryPort; + private final BookCommandPort bookCommandPort; + private final SavedCommandPort savedCommandPort; + private final SavedQueryPort savedQueryPort; + + @Override + @Transactional + public BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userId) { + + Book book; + + try { + // Book ์กฐํšŒ ์‹œ๋„ + book = bookCommandPort.findByIsbn(isbn); + } catch (EntityNotFoundException e) { + // ์ฑ…์ด DB์— ์—†์„ ๋•Œ ์ฒ˜๋ฆฌ + + if (!isSave) { + // ์‚ญ์ œ ์š”์ฒญ์ธ๋ฐ ์ฑ…์ด ์—†์œผ๋ฉด ์ €์žฅํ•˜์ง€ ์•Š์€ ์ฑ…์ด๋ฏ€๋กœ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ + throw new BusinessException(BOOK_NOT_SAVED_DB_CANNOT_DELETE); + } + + // ์ €์žฅ ์š”์ฒญ์ด๋ฉด ๋„ค์ด๋ฒ„ API๋กœ ์ฑ… ์ •๋ณด ์กฐํšŒ ํ›„ ์ €์žฅ + NaverDetailBookParseResult naverResult = bookApiQueryPort.findDetailBookByKeyword(isbn); + Book newBook = Book.withoutId( + naverResult.title(), + naverResult.isbn(), + naverResult.author(), + false, + naverResult.publisher(), + naverResult.imageUrl(), + null, + naverResult.description()); + + Long newBookId = bookCommandPort.save(newBook); + book = newBook.withId(newBookId); + } + + // ์œ ์ €๊ฐ€ ์ €์žฅํ•œ ์ฑ… ๋ชฉ๋ก ์กฐํšŒ + SavedBooks savedBooks = savedQueryPort.findByUserId(userId); + + if (isSave) { + // ์ €์žฅ ์š”์ฒญ ์‹œ ์ด๋ฏธ ์ €์žฅ๋˜์–ด ์žˆ์œผ๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ + savedBooks.validateNotAlreadySaved(book); + savedCommandPort.saveBook(userId, book.getId()); + } else { + // ์‚ญ์ œ ์š”์ฒญ ์‹œ ์ €์žฅ๋˜์–ด ์žˆ์ง€ ์•Š์œผ๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ + savedBooks.validateCanDelete(book); + savedCommandPort.deleteBook(userId, book.getId()); + } + + return BookIsSavedResult.of(isbn, isSave); + } + +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java index 3ec280a6c..e0afa354f 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java @@ -9,6 +9,7 @@ import konkuk.thip.book.application.port.out.BookApiQueryPort; import konkuk.thip.book.domain.Book; import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.feed.application.port.out.FeedQueryPort; import konkuk.thip.recentSearch.application.port.out.RecentSearchCommandPort; import konkuk.thip.recentSearch.domain.RecentSearch; @@ -22,7 +23,6 @@ import java.time.LocalDate; import java.util.HashSet; -import java.util.Optional; import java.util.Set; import static konkuk.thip.book.adapter.out.api.NaverApiUtil.PAGE_SIZE; @@ -85,21 +85,20 @@ public BookDetailSearchResult searchDetailBooks(String isbn,Long userId) { //์ฑ… ์ƒ์„ธ์ •๋ณด NaverDetailBookParseResult naverDetailBookParseResult = bookApiQueryPort.findDetailBookByKeyword(isbn); - - Optional bookOpt = bookCommandPort.findByIsbn(isbn); - - if (bookOpt.isEmpty()) { - // ์ฑ…์ด ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋ฐ˜ํ™˜ + Book book; + try { + // DB์—์„œ ์ฑ… ์ •๋ณด ์กฐํšŒ (์—†์œผ๋ฉด ์˜ˆ์™ธ ๋ฐœ์ƒ) + book = bookCommandPort.findByIsbn(isbn); + } catch (EntityNotFoundException e) { + // ์ฑ…์ด DB์— ์—†์œผ๋ฉด ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ๋ฐ˜ํ™˜ return BookDetailSearchResult.of( naverDetailBookParseResult, - 0, - 0, - false + 0, // ๋ชจ์ง‘ ์ค‘์ธ ๋ฐฉ ๊ฐœ์ˆ˜ + 0, // ์ฝ๊ธฐ ์ฐธ์—ฌ์ž ์ˆ˜ + false // ์ €์žฅ ์—ฌ๋ถ€ ); } - Book book = bookOpt.get(); - //์ด์ฑ…์— ๋ชจ์ง‘์ค‘์ธ ๋ชจ์ž„๋ฐฉ ๊ฐœ์ˆ˜ int recruitingRoomCount = getRecruitingRoomCount(book); // ์ด์ฑ…์— ์ฝ๊ธฐ ์ฐธ์—ฌ์ค‘์ธ ์‚ฌ์šฉ์ž ์ˆ˜ diff --git a/src/main/java/konkuk/thip/book/domain/Book.java b/src/main/java/konkuk/thip/book/domain/Book.java index 79020d207..3b0e53723 100644 --- a/src/main/java/konkuk/thip/book/domain/Book.java +++ b/src/main/java/konkuk/thip/book/domain/Book.java @@ -4,6 +4,8 @@ import lombok.Getter; import lombok.experimental.SuperBuilder; +import java.util.Objects; + @Getter @SuperBuilder public class Book extends BaseDomainEntity { @@ -26,4 +28,46 @@ public class Book extends BaseDomainEntity { private String description; + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof Book)) return false; + Book book = (Book) o; + return Objects.equals(isbn, book.isbn); + } + + @Override + public int hashCode() { + return Objects.hash(isbn); + } + + public static Book withoutId(String title, String isbn, String authorName, boolean bestSeller, String publisher, String imageUrl,Integer pageCount, String description) { + return Book.builder() + .id(null) + .title(title) + .isbn(isbn) + .authorName(authorName) + .bestSeller(bestSeller) + .publisher(publisher) + .imageUrl(imageUrl) + .pageCount(pageCount) + .description(description) + .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(); + } + + } diff --git a/src/main/java/konkuk/thip/book/domain/SavedBooks.java b/src/main/java/konkuk/thip/book/domain/SavedBooks.java new file mode 100644 index 000000000..ba5e5f8b3 --- /dev/null +++ b/src/main/java/konkuk/thip/book/domain/SavedBooks.java @@ -0,0 +1,38 @@ +package konkuk.thip.book.domain; + +import konkuk.thip.common.exception.BusinessException; +import lombok.Getter; + +import java.util.*; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + +@Getter +public class SavedBooks { + private final Set books; + + public SavedBooks(List books) { + // Set์œผ๋กœ ๋ณ€ํ™˜ํ•ด์„œ ์ค‘๋ณต ์—ฌ๋ถ€ ๊ฒ€์‚ฌ + Set bookSet = new HashSet<>(books); + if (bookSet.size() != books.size()) { + throw new BusinessException(DUPLICATED_BOOKS_IN_COLLECTION); + } + // ๋ถˆ๋ณ€ Set์œผ๋กœ ์ €์žฅ (Collections.unmodifiableSet ์‚ฌ์šฉ) + this.books = Collections.unmodifiableSet(bookSet); + } + + // ์ค‘๋ณต ์ €์žฅ ๊ฒ€์ฆ + public void validateNotAlreadySaved(Book book) { + if (books.contains(book)) { + throw new BusinessException(BOOK_ALREADY_SAVED); + } + } + + // ์‚ญ์ œ ๊ฐ€๋Šฅ ์—ฌ๋ถ€ ๊ฒ€์ฆ + public void validateCanDelete(Book book) { + if (!books.contains(book)) { + throw new BusinessException(BOOK_NOT_SAVED_CANNOT_DELETE); + } + } +} + 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 a8540e1d6..76dea00c3 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -49,6 +49,10 @@ public enum ErrorCode implements ResponseCode { BOOK_PAGE_NUMBER_INVALID(HttpStatus.BAD_REQUEST, 80008, "ํŽ˜์ด์ง€ ๋ฒˆํ˜ธ๋Š” 1 ์ด์ƒ์˜ ๊ฐ’์ด์–ด์•ผ ํ•ฉ๋‹ˆ๋‹ค."), BOOK_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80009, "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์— ์กด์žฌํ•˜์ง€ ์•Š์€ ์ฑ…์€ ์ €์žฅ์‚ญ์ œ ํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."), diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java index f0a11dc98..97a2f6ed5 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedBookJpaRepository.java @@ -3,6 +3,10 @@ import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + public interface SavedBookJpaRepository extends JpaRepository { boolean existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(Long userId, Long bookId); + void deleteByUserJpaEntity_UserIdAndBookJpaEntity_BookId(Long userId, Long bookId); + List findByUserJpaEntity_UserId(Long userId); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java index ab97fb9ac..da0071bad 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedCommandPersistenceAdapter.java @@ -1,18 +1,47 @@ package konkuk.thip.saved.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.saved.adapter.out.jpa.SavedBookJpaEntity; import konkuk.thip.saved.adapter.out.mapper.SavedBookMapper; import konkuk.thip.saved.adapter.out.mapper.SavedFeedMapper; import konkuk.thip.saved.application.port.out.SavedCommandPort; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.common.exception.code.ErrorCode.*; + @Repository @RequiredArgsConstructor public class SavedCommandPersistenceAdapter implements SavedCommandPort { + private final UserJpaRepository userJpaRepository; + private final BookJpaRepository bookJpaRepository; private final SavedBookJpaRepository savedBookJpaRepository; private final SavedFeedJpaRepository savedFeedJpaRepository; private final SavedBookMapper savedBookMapper; private final SavedFeedMapper savedFeedMapper; -} + @Override + public void saveBook(Long userId, Long bookId) { + UserJpaEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + BookJpaEntity book = bookJpaRepository.findById(bookId) + .orElseThrow(() -> new EntityNotFoundException(BOOK_NOT_FOUND)); + SavedBookJpaEntity entity = SavedBookJpaEntity.builder() + .userJpaEntity(user) + .bookJpaEntity(book) + .build(); + savedBookJpaRepository.save(entity); + } + + + //์‚ญ์ œ ์ „๋žต ๋„์ž… ์ „ + @Override + public void deleteBook(Long userId, Long bookId) { + savedBookJpaRepository.deleteByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookId); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java index 248d2274e..814525633 100644 --- a/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/saved/adapter/out/persistence/SavedQueryPersistenceAdapter.java @@ -1,22 +1,53 @@ package konkuk.thip.saved.adapter.out.persistence; +import konkuk.thip.book.adapter.out.mapper.BookMapper; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; import konkuk.thip.saved.adapter.out.mapper.SavedBookMapper; import konkuk.thip.saved.adapter.out.mapper.SavedFeedMapper; import konkuk.thip.saved.application.port.out.SavedQueryPort; +import konkuk.thip.book.domain.SavedBooks; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.stream.Collectors; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + @Repository @RequiredArgsConstructor public class SavedQueryPersistenceAdapter implements SavedQueryPort { private final SavedBookJpaRepository savedBookJpaRepository; private final SavedFeedJpaRepository savedFeedJpaRepository; + private final UserJpaRepository userJpaRepository; private final SavedBookMapper savedBookMapper; + private final BookMapper bookMapper; private final SavedFeedMapper savedFeedMapper; @Override public boolean existsByUserIdAndBookId(Long userId, Long bookId) { return savedBookJpaRepository.existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookId); } + + @Override + public SavedBooks findByUserId(Long userId) { + + UserJpaEntity user = userJpaRepository.findById(userId) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + List savedBookEntities = savedBookJpaRepository.findByUserJpaEntity_UserId(user.getUserId()); + + // SavedBookJpaEntity์—์„œ BookJpaEntity๋ฅผ ๊บผ๋‚ด ๋„๋ฉ”์ธ Book์œผ๋กœ ๋ณ€ํ™˜ + List books = savedBookEntities.stream() + .map(entity -> bookMapper.toDomainEntity(entity.getBookJpaEntity())) + .collect(Collectors.toList()); + + return new SavedBooks(books); + } + + } diff --git a/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java b/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java index 12ddaa1fd..a4a12157f 100644 --- a/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java +++ b/src/main/java/konkuk/thip/saved/application/port/out/SavedCommandPort.java @@ -2,5 +2,6 @@ public interface SavedCommandPort { - + void saveBook(Long userId, Long bookId); + void deleteBook(Long userId, Long bookId); } diff --git a/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java b/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java index f7826ede0..1ed5ae9e6 100644 --- a/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java +++ b/src/main/java/konkuk/thip/saved/application/port/out/SavedQueryPort.java @@ -1,5 +1,8 @@ package konkuk.thip.saved.application.port.out; +import konkuk.thip.book.domain.SavedBooks; + public interface SavedQueryPort { boolean existsByUserIdAndBookId(Long userId, Long bookId); + SavedBooks findByUserId(Long userId); } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookChangeSavedControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookChangeSavedControllerTest.java new file mode 100644 index 000000000..871ded7f7 --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookChangeSavedControllerTest.java @@ -0,0 +1,248 @@ +package konkuk.thip.book.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.adapter.in.web.request.PostBookIsSavedRequest; +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.BookJpaRepository; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.security.util.JwtUtil; +import konkuk.thip.saved.adapter.out.jpa.SavedBookJpaEntity; +import konkuk.thip.saved.adapter.out.persistence.SavedBookJpaRepository; +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.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.util.Optional; + +import static org.assertj.core.api.AssertionsForClassTypes.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 +@AutoConfigureMockMvc +@ActiveProfiles("test") +class BookChangeSavedControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private BookJpaRepository bookJpaRepository; + + @Autowired + private SavedBookJpaRepository savedBookJpaRepository; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private ObjectMapper objectMapper; + + private String testToken; + private Long userId; + private String testIsbn = "1234567890123"; + + @BeforeEach + void setUp() { + + AliasJpaEntity alias = aliasJpaRepository.save(AliasJpaEntity.builder() + .value("์ฑ…๋ฒŒ๋ ˆ") + .color("blue") + .imageUrl("http://image.url") + .build()); + + UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_432708231") + .nickname("User1") + .imageUrl("https://avatar1.jpg") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + // ํ…Œ์ŠคํŠธ์ฑ… ๋ฏธ๋ฆฌ ์ €์žฅ + BookJpaEntity book = bookJpaRepository.save(BookJpaEntity.builder() + .isbn(testIsbn) + .title("ํ…Œ์ŠคํŠธ์ฑ…") + .imageUrl("https://image.url") + .authorName("์ €์ž") + .publisher("์ถœํŒ์‚ฌ") + .description("์„ค๋ช…") + .bestSeller(false) + .build()); + + userId = user.getUserId(); + testToken = jwtUtil.createAccessToken(userId); + + } + + @AfterEach + void tearDown() { + savedBookJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + @Test + @DisplayName("DB์— ์ฑ…์ด ์กด์žฌํ•˜๊ณ  ํ•ด๋‹น ์ฑ…์„ ์ €์žฅํ•˜๋ ค๊ณ  ํ• ๋•Œ [์ฑ… ์ €์žฅ ์„ฑ๊ณต]") + void saveBook_success() throws Exception { + + // given + PostBookIsSavedRequest request = new PostBookIsSavedRequest(true); + + //when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", testIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isbn").value(testIsbn)) + .andExpect(jsonPath("$.data.isSaved").value(true)); + + // ์‹ค์ œ ์ €์žฅ๋๋Š”์ง€ ๊ฒ€์ฆ + Optional bookJpaEntity = bookJpaRepository.findByIsbn(testIsbn); + boolean exists = savedBookJpaRepository.existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookJpaEntity.get().getBookId()); + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("DB์— ์ฑ…์ด ์กด์žฌํ•˜์ง€ ์•Š์„ ๋•Œ ํ•ด๋‹น ์ฑ…์„ DB์— ์ €์žฅํ•˜๊ณ , ํ•ด๋‹น ์ฑ…์„ ์ €์žฅํ•˜๋ ค๊ณ  ํ•  ๋•Œ [์ฑ… ์ €์žฅ ์„ฑ๊ณต]") + void saveBook_whenBookNotExist_thenSaveAndSuccess() throws Exception { + + // given + String newIsbn = "9791195710447"; // DB์— ์—†๊ณ  ์‹ค์ œ ์กด์žฌํ•˜๋Š” ์ฑ… ISBN + PostBookIsSavedRequest request = new PostBookIsSavedRequest(true); + + // when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", newIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isbn").value(newIsbn)) + .andExpect(jsonPath("$.data.isSaved").value(true)); + + // ์‹ค์ œ ์ €์žฅ๋๋Š”์ง€ ๊ฒ€์ฆ + Optional bookJpaEntity = bookJpaRepository.findByIsbn(newIsbn); + assertThat(bookJpaEntity).isPresent(); + boolean exists = savedBookJpaRepository.existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(userId, bookJpaEntity.get().getBookId()); + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("์ด๋ฏธ ์ €์žฅ๋œ ์ฑ…์„ ์ €์žฅํ•˜๋ ค๊ณ ํ•˜๋ฉด [400 ์—๋Ÿฌ ๋ฐœ์ƒ]") + void saveBook_alreadySaved_fail() throws Exception { + + // given + BookJpaEntity book = bookJpaRepository.findByIsbn(testIsbn).get(); + UserJpaEntity user = userJpaRepository.findById(userId).get(); + savedBookJpaRepository.save(SavedBookJpaEntity.builder() + .userJpaEntity(user) + .bookJpaEntity(book) + .build()); + + PostBookIsSavedRequest request = new PostBookIsSavedRequest(true); + + //when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", testIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + //then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_ALREADY_SAVED.getCode())); + + } + + @Test + @DisplayName("์ด๋ฏธ ์ €์žฅํ•œ ์ฑ… [์ฑ… ์ €์žฅ ์‚ญ์ œ ์„ฑ๊ณต]") + void deleteBook_success() throws Exception { + + // given + BookJpaEntity book = bookJpaRepository.findByIsbn(testIsbn).get(); + UserJpaEntity user = userJpaRepository.findById(userId).get(); + savedBookJpaRepository.save(SavedBookJpaEntity.builder() + .userJpaEntity(user) + .bookJpaEntity(book) + .build()); + + PostBookIsSavedRequest request = new PostBookIsSavedRequest(false); + + //when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", testIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isbn").value(testIsbn)) + .andExpect(jsonPath("$.data.isSaved").value(false)); + + // ์‹ค์ œ ์‚ญ์ œ๋๋Š”์ง€ ๊ฒ€์ฆ + boolean exists = savedBookJpaRepository.existsByUserJpaEntity_UserIdAndBookJpaEntity_BookId(user.getUserId(), book.getBookId()); + assertThat(exists).isFalse(); + + } + + @Test + @DisplayName("์ €์žฅํ•˜์ง€ ์•Š์€ ์ฑ…์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด [400 ์• ๋Ÿฌ ๋ฐœ์ƒ]") + void deleteBook_notSaved_fail() throws Exception { + + // given + PostBookIsSavedRequest request = new PostBookIsSavedRequest(false); + + //when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", testIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + //then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_NOT_SAVED_CANNOT_DELETE.getCode())); + + } + + @Test + @DisplayName("DB์— ์กด์žฌํ•˜์ง€ ์•Š๋Š” ์ฑ…์„ ์‚ญ์ œํ•˜๋ ค๊ณ  ํ•˜๋ฉด [400 ์—๋Ÿฌ ๋ฐœ์ƒ]") + void deleteBook_whenBookNotExist_thenFail() throws Exception { + // given + String newIsbn = "9791195710447"; // DB์— ์—†๊ณ  ์‹ค์ œ ์กด์žฌํ•˜๋Š” ์ฑ… ISBN + PostBookIsSavedRequest request = new PostBookIsSavedRequest(false); + + // when + ResultActions result = mockMvc.perform(post("/books/{isbn}/saved", newIsbn) + .header("Authorization", "Bearer " + testToken) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + result.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.BOOK_NOT_SAVED_DB_CANNOT_DELETE.getCode())); + } + +}