diff --git a/src/main/java/com/dokdok/meeting/api/MeetingApi.java b/src/main/java/com/dokdok/meeting/api/MeetingApi.java index 9d43f18..10cebc0 100644 --- a/src/main/java/com/dokdok/meeting/api/MeetingApi.java +++ b/src/main/java/com/dokdok/meeting/api/MeetingApi.java @@ -14,6 +14,7 @@ import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.parameters.RequestBody; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.http.MediaType; @@ -56,6 +57,7 @@ public interface MeetingApi { "book": { "bookId": 1, "bookName": "클린 코드", + "authors": "로버트 C. 마틴", "thumbnail": "https://example.com/thumb.jpg" }, "schedule": { @@ -120,7 +122,7 @@ ResponseEntity> findMeeting( summary = "약속 생성 신청 (developer: 김윤영)", description = """ 모임 구성원이 약속 생성을 신청합니다. - - 입력: 약속 제목(미입력 시 책 제목), 책 제목*, 약속 일시*, 최대 인원 수(null 허용), 장소 정보(null 허용) + - 입력: 약속 제목(미입력 시 책 제목), 책 정보(제목/저자/출판사/ISBN/썸네일), 약속 일시*, 최대 인원 수(null 허용), 장소 정보(null 허용) - 권한: 해당 모임의 구성원 """ ) @@ -144,7 +146,8 @@ ResponseEntity> findMeeting( }, "book": { "bookId": 1, - "bookName": "클린 코드" + "bookName": "클린 코드", + "thumbnail": "https://example.com/thumb.jpg" }, "schedule": { "date": "2025-02-01", @@ -190,6 +193,31 @@ ResponseEntity> findMeeting( """))) }) ResponseEntity> createMeeting( + @RequestBody( + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + examples = @ExampleObject(value = """ + { + "gatheringId": 1, + "book": { + "title": "클린 코드", + "authors": "로버트 C. 마틴", + "publisher": "인사이트", + "isbn": "9788966260959", + "thumbnail": "https://example.com/thumb.jpg" + }, + "meetingName": "1월 독서 모임", + "meetingStartDate": "2025-02-01T14:00:00", + "meetingEndDate": "2025-02-01T16:00:00", + "maxParticipants": 10, + "location": { + "name": "강남 스터디룸 A", + "address": "서울 강남구 ...", + "latitude": 37.4979, + "longitude": 127.0276 + } + } + """)) + ) MeetingCreateRequest request ); diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java b/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java index 7f84a30..f7511c5 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java @@ -2,6 +2,8 @@ import com.dokdok.meeting.entity.MeetingLocation; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Builder; @@ -14,8 +16,10 @@ public record MeetingCreateRequest( @Schema(description = "모임 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) Long gatheringId, - @Schema(description = "책 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) - Long bookId, + @Schema(description = "책 정보", requiredMode = Schema.RequiredMode.REQUIRED) + @Valid + @NotNull + BookInfo book, @Schema(description = "약속 이름 (미입력 시 책 제목 사용)", example = "1월 독서 모임", minLength = 1, maxLength = 24) @Size(min = 1, max = 24) @@ -41,4 +45,27 @@ public MeetingLocation toLocationEntity() { } return location.toEntity(); } + + @Schema(description = "책 정보") + public record BookInfo( + @Schema(description = "책 제목", example = "클린 코드", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String title, + + @Schema(description = "저자", example = "로버트 C. 마틴", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String authors, + + @Schema(description = "출판사", example = "인사이트", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String publisher, + + @Schema(description = "ISBN", example = "9788966260959", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank + String isbn, + + @Schema(description = "썸네일 URL", example = "https://example.com/thumb.jpg") + String thumbnail + ) { + } } diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java index 01f2bbe..5dd51db 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingDetailResponse.java @@ -191,6 +191,9 @@ public record BookInfo( @Schema(description = "책 이름", example = "클린 코드") String bookName, + @Schema(description = "저자", example = "로버트 C. 마틴") + String authors, + @Schema(description = "책 썸네일 URL", example = "https://example.com/thumb.jpg") String thumbnail ) { @@ -198,7 +201,7 @@ public static BookInfo from(Book book) { if (book == null) { return null; } - return new BookInfo(book.getId(), book.getBookName(), book.getThumbnail()); + return new BookInfo(book.getId(), book.getBookName(), book.getAuthor(), book.getThumbnail()); } } diff --git a/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java b/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java index 2eb9c75..c8cb466 100644 --- a/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java +++ b/src/main/java/com/dokdok/meeting/dto/MeetingResponse.java @@ -78,13 +78,16 @@ public record BookInfo( Long bookId, @Schema(description = "책 이름", example = "클린 코드") - String bookName + String bookName, + + @Schema(description = "책 썸네일 URL", example = "https://example.com/thumb.jpg") + String thumbnail ) { public static BookInfo from(Book book) { if (book == null) { return null; } - return new BookInfo(book.getId(), book.getBookName()); + return new BookInfo(book.getId(), book.getBookName(), book.getThumbnail()); } } diff --git a/src/main/java/com/dokdok/meeting/service/MeetingService.java b/src/main/java/com/dokdok/meeting/service/MeetingService.java index 7c8da29..2cfce18 100644 --- a/src/main/java/com/dokdok/meeting/service/MeetingService.java +++ b/src/main/java/com/dokdok/meeting/service/MeetingService.java @@ -5,7 +5,6 @@ import com.dokdok.book.exception.BookErrorCode; import com.dokdok.book.exception.BookException; import com.dokdok.book.repository.BookRepository; -import com.dokdok.book.service.BookService; import com.dokdok.book.service.BookValidator; import com.dokdok.book.service.PersonalBookService; import com.dokdok.gathering.entity.Gathering; @@ -106,8 +105,17 @@ public MeetingResponse createMeeting(MeetingCreateRequest request) { Gathering gathering = gatheringRepository.findById(request.gatheringId()) .orElseThrow(() -> new GatheringException(GatheringErrorCode.GATHERING_NOT_FOUND)); - Book book = bookRepository.findById(request.bookId()) - .orElseThrow(() -> new BookException(BookErrorCode.BOOK_NOT_FOUND)); + Book book = bookRepository.findByIsbn(request.book().isbn()) + .orElseGet(() -> { + BookCreateRequest bookCreateRequest = new BookCreateRequest( + request.book().title(), + request.book().authors(), + request.book().publisher(), + request.book().isbn(), + request.book().thumbnail() + ); + return bookRepository.save(bookCreateRequest.of()); + }); User user = userValidator.findUserOrThrow(userId); @@ -146,6 +154,7 @@ public MeetingStatusResponse confirmMeeting(Long meetingId) { ensureLeaderMember(meeting); meeting.changeStatus(MeetingStatus.CONFIRMED); + saveMeetingBookForUser(meeting, meeting.getGathering(), meeting.getMeetingLeader().getId()); return MeetingStatusResponse.from(meeting); } @@ -258,12 +267,19 @@ public Long joinMeeting(Long meetingId) { * 약속 참가 신청 성공 시 책장에 등록한다. */ private void saveMeetingBook(Meeting meeting, Gathering gathering) { + Long userId = SecurityUtil.getCurrentUserId(); + saveMeetingBookForUser(meeting, gathering, userId); + } + + /** + * 특정 사용자 책장에 약속 책을 등록한다. + */ + private void saveMeetingBookForUser(Meeting meeting, Gathering gathering, Long userId) { Book book = meeting.getBook(); if (book == null) { throw new BookException(BookErrorCode.BOOK_NOT_FOUND); } - Long userId = SecurityUtil.getCurrentUserId(); if (bookValidator.isDuplicatePersonalBook(userId, book.getId())) { return; } diff --git a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java index f56bcee..8c0abdb 100644 --- a/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java +++ b/src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java @@ -1,8 +1,6 @@ package com.dokdok.meeting.service; import com.dokdok.book.entity.Book; -import com.dokdok.book.exception.BookErrorCode; -import com.dokdok.book.exception.BookException; import com.dokdok.book.repository.BookRepository; import com.dokdok.book.service.BookValidator; import com.dokdok.book.service.PersonalBookService; @@ -120,6 +118,7 @@ void setUp() { .meetingEndDate(LocalDateTime.now().plusDays(2).plusHours(1)) .meetingLeader(leader) .gathering(gathering) + .book(sampleBook()) .build(); } @@ -350,14 +349,21 @@ void givenDoneMeeting_whenDeleteMeeting_thenThrowException() { void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { // given Long gatheringId = 3L; - Long bookId = 12L; Long userId = 7L; + String title = "book"; + String authors = "author"; + String publisher = "publisher"; + String isbn = "9781234567890"; + String thumbnail = "https://example.com/thumb.jpg"; + MeetingCreateRequest.BookInfo bookInfo = new MeetingCreateRequest.BookInfo( + title, authors, publisher, isbn, thumbnail + ); LocalDateTime startDate = LocalDateTime.of(2024, 1, 20, 20, 0); LocalDateTime endDate = LocalDateTime.of(2024, 1, 20, 22, 0); int memberCount = 5; MeetingCreateRequest request = MeetingCreateRequest.builder() .gatheringId(gatheringId) - .bookId(bookId) + .book(bookInfo) .meetingName(null) .meetingStartDate(startDate) .meetingEndDate(endDate) @@ -377,8 +383,12 @@ void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { .build(); Book book = Book.builder() - .id(bookId) - .bookName("book") + .id(12L) + .bookName(title) + .author(authors) + .publisher(publisher) + .isbn(isbn) + .thumbnail(thumbnail) .build(); Meeting savedMeeting = Meeting.builder() @@ -397,7 +407,7 @@ void givenMeetingCreateRequest_whenCreateMeeting_thenMeetingResponse() { .willReturn(Optional.of(gathering)); given(gatheringMemberRepository.countByGatheringIdAndRemovedAtIsNull(gatheringId)) .willReturn(memberCount); - given(bookRepository.findById(bookId)) + given(bookRepository.findByIsbn(isbn)) .willReturn(Optional.of(book)); given(userValidator.findUserOrThrow(userId)) .willReturn(user); @@ -426,8 +436,12 @@ void givenMissingGathering_whenCreateMeeting_thenThrowGatheringException() { // given Long gatheringId = 3L; Long userId = 7L; + MeetingCreateRequest.BookInfo bookInfo = new MeetingCreateRequest.BookInfo( + "book", "author", "publisher", "9781234567890", "https://example.com/thumb.jpg" + ); MeetingCreateRequest request = MeetingCreateRequest.builder() .gatheringId(gatheringId) + .book(bookInfo) .build(); given(gatheringRepository.findById(gatheringId)) @@ -444,16 +458,28 @@ void givenMissingGathering_whenCreateMeeting_thenThrowGatheringException() { } } - @DisplayName("책을 찾지 못하면 약속 생성 요청이 실패한다.") + @DisplayName("책이 없으면 새로 생성해 약속을 생성한다.") @Test - void givenMissingBook_whenCreateMeeting_thenThrowBookException() { + void givenMissingBook_whenCreateMeeting_thenCreateBook() { // given Long gatheringId = 3L; - Long bookId = 12L; Long userId = 7L; + String title = "book"; + String authors = "author"; + String publisher = "publisher"; + String isbn = "9781234567890"; + String thumbnail = "https://example.com/thumb.jpg"; + LocalDateTime startDate = LocalDateTime.of(2024, 1, 20, 20, 0); + LocalDateTime endDate = LocalDateTime.of(2024, 1, 20, 22, 0); + MeetingCreateRequest.BookInfo bookInfo = new MeetingCreateRequest.BookInfo( + title, authors, publisher, isbn, thumbnail + ); MeetingCreateRequest request = MeetingCreateRequest.builder() .gatheringId(gatheringId) - .bookId(bookId) + .book(bookInfo) + .meetingStartDate(startDate) + .meetingEndDate(endDate) + .maxParticipants(1) .build(); given(gatheringRepository.findById(gatheringId)) @@ -462,17 +488,49 @@ void givenMissingBook_whenCreateMeeting_thenThrowBookException() { .gatheringName("gathering") .invitationLink("link") .build())); - given(bookRepository.findById(bookId)) + given(gatheringMemberRepository.countByGatheringIdAndRemovedAtIsNull(gatheringId)) + .willReturn(5); + given(bookRepository.findByIsbn(isbn)) .willReturn(Optional.empty()); + given(bookRepository.save(any(Book.class))) + .willAnswer(invocation -> { + Book saved = invocation.getArgument(0); + return Book.builder() + .id(12L) + .bookName(saved.getBookName()) + .author(saved.getAuthor()) + .publisher(saved.getPublisher()) + .isbn(saved.getIsbn()) + .thumbnail(saved.getThumbnail()) + .build(); + }); + given(userValidator.findUserOrThrow(userId)) + .willReturn(User.builder().id(userId).nickname("leader").build()); + given(meetingRepository.save(any(Meeting.class))) + .willAnswer(invocation -> { + Meeting meeting = invocation.getArgument(0); + return Meeting.builder() + .id(25L) + .gathering(meeting.getGathering()) + .book(meeting.getBook()) + .meetingLeader(meeting.getMeetingLeader()) + .meetingName(meeting.getMeetingName()) + .meetingStatus(meeting.getMeetingStatus()) + .maxParticipants(meeting.getMaxParticipants()) + .meetingStartDate(meeting.getMeetingStartDate()) + .meetingEndDate(meeting.getMeetingEndDate()) + .build(); + }); try (MockedStatic securityUtilMock = mockStatic(SecurityUtil.class)) { securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); - // when + then - assertThatThrownBy(() -> meetingService.createMeeting(request)) - .isInstanceOf(BookException.class) - .extracting("errorCode") - .isEqualTo(BookErrorCode.BOOK_NOT_FOUND); + // when + MeetingResponse response = meetingService.createMeeting(request); + + // then + assertThat(response.meetingId()).isEqualTo(25L); + assertThat(response.book().bookName()).isEqualTo(title); } }