Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 30 additions & 2 deletions src/main/java/com/dokdok/meeting/api/MeetingApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -56,6 +57,7 @@ public interface MeetingApi {
"book": {
"bookId": 1,
"bookName": "클린 코드",
"authors": "로버트 C. 마틴",
"thumbnail": "https://example.com/thumb.jpg"
},
"schedule": {
Expand Down Expand Up @@ -120,7 +122,7 @@ ResponseEntity<ApiResponse<MeetingDetailResponse>> findMeeting(
summary = "약속 생성 신청 (developer: 김윤영)",
description = """
모임 구성원이 약속 생성을 신청합니다.
- 입력: 약속 제목(미입력 시 책 제목), 책 제목*, 약속 일시*, 최대 인원 수(null 허용), 장소 정보(null 허용)
- 입력: 약속 제목(미입력 시 책 제목), 책 정보(제목/저자/출판사/ISBN/썸네일), 약속 일시*, 최대 인원 수(null 허용), 장소 정보(null 허용)
- 권한: 해당 모임의 구성원
"""
)
Expand All @@ -144,7 +146,8 @@ ResponseEntity<ApiResponse<MeetingDetailResponse>> findMeeting(
},
"book": {
"bookId": 1,
"bookName": "클린 코드"
"bookName": "클린 코드",
"thumbnail": "https://example.com/thumb.jpg"
},
"schedule": {
"date": "2025-02-01",
Expand Down Expand Up @@ -190,6 +193,31 @@ ResponseEntity<ApiResponse<MeetingDetailResponse>> findMeeting(
""")))
})
ResponseEntity<ApiResponse<MeetingResponse>> 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
);

Expand Down
31 changes: 29 additions & 2 deletions src/main/java/com/dokdok/meeting/dto/MeetingCreateRequest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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
) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -191,14 +191,17 @@ 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
) {
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());
}
}

Expand Down
7 changes: 5 additions & 2 deletions src/main/java/com/dokdok/meeting/dto/MeetingResponse.java
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}

Expand Down
24 changes: 20 additions & 4 deletions src/main/java/com/dokdok/meeting/service/MeetingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand Down
92 changes: 75 additions & 17 deletions src/test/java/com/dokdok/meeting/service/MeetingServiceTest.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -120,6 +118,7 @@ void setUp() {
.meetingEndDate(LocalDateTime.now().plusDays(2).plusHours(1))
.meetingLeader(leader)
.gathering(gathering)
.book(sampleBook())
.build();
}

Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand All @@ -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);
Expand Down Expand Up @@ -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))
Expand All @@ -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))
Expand All @@ -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<SecurityUtil> 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);
}
}

Expand Down