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 d9f97b0c2..34f572296 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 @@ -30,4 +30,13 @@ public Long save(Book book) { BookJpaEntity bookJpaEntity = bookMapper.toJpaEntity(book); return bookJpaRepository.save(bookJpaEntity).getBookId(); } + + @Override + public Book findById(Long id) { + BookJpaEntity bookJpaEntity = bookJpaRepository.findById(id).orElseThrow( + () -> new EntityNotFoundException(BOOK_NOT_FOUND) + ); + + return bookMapper.toDomainEntity(bookJpaEntity); + } } 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 649b2bf8e..a1c83f9d8 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 @@ -3,10 +3,11 @@ import konkuk.thip.book.domain.Book; -import java.util.Optional; - public interface BookCommandPort { Book findByIsbn(String isbn); + Long save(Book book); + + Book findById(Long id); } \ No newline at end of file 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 76dea00c3..5de2c7ec8 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -54,13 +54,25 @@ public enum ErrorCode implements ResponseCode { BOOK_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80013, "사용자가 저장하지 않은 책은 저장삭제 할 수 없습니다."), BOOK_NOT_SAVED_DB_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80014, "DB에 존재하지 않은 책은 저장삭제 할 수 없습니다."), + /** + * 90000 : recentSearch error + */ + INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 900000,"알맞은 검색어 타입을 찾을 수 없습니다."), /** - * 90000 : recentSearch error + * 100000 : room error + */ + ROOM_NOT_FOUND(HttpStatus.NOT_FOUND, 100000, "존재하지 않는 ROOM 입니다."), + + /** + * 110000 : vote error */ - INVALID_SEARCH_TYPE(HttpStatus.BAD_REQUEST, 900000,"알맞은 검색어 타입을 찾을 수 없습니다."); + VOTE_NOT_FOUND(HttpStatus.NOT_FOUND, 110000, "존재하지 않는 VOTE 입니다."), + VOTE_CANNOT_BE_OVERVIEW(HttpStatus.BAD_REQUEST, 110001, "총평이 될 수 없는 VOTE 입니다."), + INVALID_VOTE_PAGE_RANGE(HttpStatus.BAD_REQUEST, 110002, "VOTE의 page 값이 유효하지 않습니다.") + ; private final HttpStatus httpStatus; private final int code; diff --git a/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java index 93dd6e739..9c8cef53d 100644 --- a/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java @@ -91,9 +91,15 @@ public ResponseEntity authExceptionHandler(AuthException e) { @ExceptionHandler(BusinessException.class) public ResponseEntity businessExceptionHandler(BusinessException e) { log.error("[BusinessExceptionHandler] {}", e.getMessage()); + + // 1) cause 에 포함된 상세 메시지를 파싱, 없다면 빈 문자열로 설정 + String detail = Optional.ofNullable(e.getCause()) + .map(Throwable::getMessage) + .orElse(""); + return ResponseEntity .status(e.getErrorCode().getHttpStatus()) - .body(ErrorResponse.of(e.getErrorCode())); + .body(ErrorResponse.of(e.getErrorCode(), detail)); } // 서버 내부 오류 예외 처리 @@ -132,5 +138,4 @@ public ResponseEntity constraintViolationExceptionHandler(Constra .body(ErrorResponse.of(API_INVALID_PARAM, errorMessage)); } - } 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 b3b8c9163..c6a607187 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,15 +1,28 @@ package konkuk.thip.room.adapter.out.persistence; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; import konkuk.thip.room.adapter.out.mapper.RoomMapper; import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.domain.Room; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.common.exception.code.ErrorCode.ROOM_NOT_FOUND; + @Repository @RequiredArgsConstructor public class RoomCommandPersistenceAdapter implements RoomCommandPort { - private final RoomJpaRepository jpaRepository; - private final RoomMapper userMapper; + private final RoomJpaRepository roomJpaRepository; + private final RoomMapper roomMapper; + + @Override + public Room findById(Long id) { + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(id).orElseThrow( + () -> new EntityNotFoundException(ROOM_NOT_FOUND) + ); + return roomMapper.toDomainEntity(roomJpaEntity); + } } 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 585323dd8..aa4ca8d28 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 @@ -1,6 +1,8 @@ package konkuk.thip.room.application.port.out; +import konkuk.thip.room.domain.Room; public interface RoomCommandPort { + Room findById(Long id); } diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index e95ab982f..55ca9a027 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -1,6 +1,7 @@ package konkuk.thip.user.adapter.in.web; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.Oauth2Id; import konkuk.thip.common.security.util.JwtUtil; @@ -11,7 +12,6 @@ import konkuk.thip.user.application.port.in.UserSignupUseCase; import konkuk.thip.user.application.port.in.UserVerifyNicknameUseCase; import lombok.RequiredArgsConstructor; -import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -28,7 +28,7 @@ public class UserCommandController { private final JwtUtil jwtUtil; @PostMapping("/users/signup") - public BaseResponse signup(@Validated @RequestBody UserSignupRequest request, + public BaseResponse signup(@Valid @RequestBody UserSignupRequest request, @Oauth2Id String oauth2Id, HttpServletResponse response) { Long userId = userSignupUseCase.signup(request.toCommand(oauth2Id)); @@ -38,7 +38,7 @@ public BaseResponse signup(@Validated @RequestBody UserSignu } @PostMapping("/users/nickname") - public BaseResponse verifyNickname(@Validated @RequestBody UserVerifyNicknameRequest request) { + public BaseResponse verifyNickname(@Valid @RequestBody UserVerifyNicknameRequest request) { return BaseResponse.ok(UserVerifyNicknameResponse.of( userVerifyNicknameUseCase.isNicknameUnique(request.nickname())) ); diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java index 8ebbbfaa9..2ed5025d5 100644 --- a/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/VoteCommandController.java @@ -1,10 +1,31 @@ package konkuk.thip.vote.adapter.in.web; +import jakarta.validation.Valid; +import konkuk.thip.common.dto.BaseResponse; +import konkuk.thip.common.security.annotation.UserId; +import konkuk.thip.vote.adapter.in.web.request.VoteCreateRequest; +import konkuk.thip.vote.adapter.in.web.response.VoteCreateResponse; +import konkuk.thip.vote.application.port.in.VoteCreateUseCase; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PathVariable; +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 VoteCommandController { + private final VoteCreateUseCase voteCreateUseCase; + + @PostMapping("/rooms/{roomId}/vote") + public BaseResponse createVote( + @UserId Long userId, + @PathVariable Long roomId, + @Valid @RequestBody VoteCreateRequest request) { + + return BaseResponse.ok(VoteCreateResponse.of( + voteCreateUseCase.createVote(request.toCommand(userId, roomId)) + )); + } } diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/request/DummyRequest.java b/src/main/java/konkuk/thip/vote/adapter/in/web/request/DummyRequest.java deleted file mode 100644 index c46b4260c..000000000 --- a/src/main/java/konkuk/thip/vote/adapter/in/web/request/DummyRequest.java +++ /dev/null @@ -1,7 +0,0 @@ -package konkuk.thip.vote.adapter.in.web.request; - -import lombok.Getter; - -@Getter -public class DummyRequest { -} diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java b/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java new file mode 100644 index 000000000..425f51517 --- /dev/null +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/request/VoteCreateRequest.java @@ -0,0 +1,48 @@ +package konkuk.thip.vote.adapter.in.web.request; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import konkuk.thip.vote.application.port.in.dto.VoteCreateCommand; + +import java.util.List; + +public record VoteCreateRequest( + @NotNull(message = "page는 필수입니다.") + Integer page, + + @NotNull(message = "isOverview(= 총평 여부)는 필수입니다.") + Boolean isOverview, + + @NotBlank(message = "투표 내용은 필수입니다.") + @Size(max = 20, message = "투표 내용은 최대 20자 입니다.") + String content, + + @NotNull(message = "투표 항목은 필수입니다.") + @Size(min = 1, max = 5, message = "투표 항목은 1개 이상, 최대 5개까지입니다.") + @Valid + List voteItemList +) { + public record VoteItemCreateRequest( + @NotBlank(message = "투표 항목 이름은 필수입니다.") + @Size(max = 20, message = "투표 항목 이름은 최대 20자입니다.") + String itemName + ) {} + + public VoteCreateCommand toCommand(Long userId, Long roomId) { + List mappedItems = voteItemList.stream() + .map(voteItem -> new VoteCreateCommand.VoteItemCreateCommand(voteItem.itemName)) + .toList(); + + return new VoteCreateCommand( + userId, + roomId, + page, + isOverview, + content, + mappedItems + ); + } + +} diff --git a/src/main/java/konkuk/thip/vote/adapter/in/web/response/VoteCreateResponse.java b/src/main/java/konkuk/thip/vote/adapter/in/web/response/VoteCreateResponse.java new file mode 100644 index 000000000..0f6989593 --- /dev/null +++ b/src/main/java/konkuk/thip/vote/adapter/in/web/response/VoteCreateResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.vote.adapter.in.web.response; + +public record VoteCreateResponse( + Long voteId +) { + public static VoteCreateResponse of(Long voteId) { + return new VoteCreateResponse(voteId); + } +} diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java index 725610788..f38b2c0bd 100644 --- a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteCommandPersistenceAdapter.java @@ -1,15 +1,62 @@ package konkuk.thip.vote.adapter.out.persistence; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.persistence.RoomJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; +import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.adapter.out.mapper.VoteItemMapper; import konkuk.thip.vote.adapter.out.mapper.VoteMapper; import konkuk.thip.vote.application.port.out.VoteCommandPort; +import konkuk.thip.vote.domain.Vote; +import konkuk.thip.vote.domain.VoteItem; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.List; + +import static konkuk.thip.common.exception.code.ErrorCode.*; + @Repository @RequiredArgsConstructor public class VoteCommandPersistenceAdapter implements VoteCommandPort { private final VoteJpaRepository voteJpaRepository; + private final VoteItemJpaRepository voteItemJpaRepository; + private final UserJpaRepository userJpaRepository; + private final RoomJpaRepository roomJpaRepository; + private final VoteMapper voteMapper; + private final VoteItemMapper voteItemMapper; + + @Override + public Long saveVote(Vote vote) { + UserJpaEntity userJpaEntity = userJpaRepository.findById(vote.getCreatorId()).orElseThrow( + () -> new EntityNotFoundException(USER_NOT_FOUND) + ); + + RoomJpaEntity roomJpaEntity = roomJpaRepository.findById(vote.getRoomId()).orElseThrow( + () -> new EntityNotFoundException(ROOM_NOT_FOUND) + ); + + return voteJpaRepository.save(voteMapper.toJpaEntity(vote, userJpaEntity, roomJpaEntity)).getPostId(); + } + + @Override + public void saveAllVoteItems(List voteItems) { + if (voteItems.isEmpty()) return; + + Long voteId = voteItems.get(0).getVoteId(); + VoteJpaEntity voteJpaEntity = voteJpaRepository.findById(voteId).orElseThrow( + () -> new EntityNotFoundException(VOTE_NOT_FOUND) + ); + + List voteItemJpaEntities = voteItems.stream() + .map(voteItem -> voteItemMapper.toJpaEntity(voteItem, voteJpaEntity)) + .toList(); + voteItemJpaRepository.saveAll(voteItemJpaEntities); + } } diff --git a/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteItemJpaRepository.java b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteItemJpaRepository.java new file mode 100644 index 000000000..3c2b86caf --- /dev/null +++ b/src/main/java/konkuk/thip/vote/adapter/out/persistence/VoteItemJpaRepository.java @@ -0,0 +1,11 @@ +package konkuk.thip.vote.adapter.out.persistence; + +import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface VoteItemJpaRepository extends JpaRepository { + + List findAllByVoteJpaEntity_PostId(Long voteId); +} diff --git a/src/main/java/konkuk/thip/vote/application/port/in/DummyUseCase.java b/src/main/java/konkuk/thip/vote/application/port/in/DummyUseCase.java deleted file mode 100644 index 4b8a8b301..000000000 --- a/src/main/java/konkuk/thip/vote/application/port/in/DummyUseCase.java +++ /dev/null @@ -1,5 +0,0 @@ -package konkuk.thip.vote.application.port.in; - -public interface DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/vote/application/port/in/VoteCreateUseCase.java b/src/main/java/konkuk/thip/vote/application/port/in/VoteCreateUseCase.java new file mode 100644 index 000000000..ea104ac4e --- /dev/null +++ b/src/main/java/konkuk/thip/vote/application/port/in/VoteCreateUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.vote.application.port.in; + +import konkuk.thip.vote.application.port.in.dto.VoteCreateCommand; + +public interface VoteCreateUseCase { + + Long createVote(VoteCreateCommand command); +} diff --git a/src/main/java/konkuk/thip/vote/application/port/in/dto/DummyCommand.java b/src/main/java/konkuk/thip/vote/application/port/in/dto/DummyCommand.java deleted file mode 100644 index f0ae41938..000000000 --- a/src/main/java/konkuk/thip/vote/application/port/in/dto/DummyCommand.java +++ /dev/null @@ -1,10 +0,0 @@ -package konkuk.thip.vote.application.port.in.dto; - -import lombok.Builder; -import lombok.Getter; - -@Builder -@Getter -public class DummyCommand { - -} diff --git a/src/main/java/konkuk/thip/vote/application/port/in/dto/VoteCreateCommand.java b/src/main/java/konkuk/thip/vote/application/port/in/dto/VoteCreateCommand.java new file mode 100644 index 000000000..b0e5d8308 --- /dev/null +++ b/src/main/java/konkuk/thip/vote/application/port/in/dto/VoteCreateCommand.java @@ -0,0 +1,19 @@ +package konkuk.thip.vote.application.port.in.dto; + +import java.util.List; + +public record VoteCreateCommand( + Long userId, + + Long roomId, + + int page, + + boolean isOverview, + + String content, + + List voteItemCreateCommands +) { + public record VoteItemCreateCommand(String itemName) {} +} diff --git a/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java b/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java index 4c6511a58..e3f6c525f 100644 --- a/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java +++ b/src/main/java/konkuk/thip/vote/application/port/out/VoteCommandPort.java @@ -1,6 +1,13 @@ package konkuk.thip.vote.application.port.out; +import konkuk.thip.vote.domain.Vote; +import konkuk.thip.vote.domain.VoteItem; + +import java.util.List; public interface VoteCommandPort { + Long saveVote(Vote vote); + + void saveAllVoteItems(List voteItems); } diff --git a/src/main/java/konkuk/thip/vote/application/service/VoteCreateService.java b/src/main/java/konkuk/thip/vote/application/service/VoteCreateService.java new file mode 100644 index 000000000..22b7c38e2 --- /dev/null +++ b/src/main/java/konkuk/thip/vote/application/service/VoteCreateService.java @@ -0,0 +1,68 @@ +package konkuk.thip.vote.application.service; + +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.room.application.port.out.RoomCommandPort; +import konkuk.thip.room.domain.Room; +import konkuk.thip.vote.application.port.in.VoteCreateUseCase; +import konkuk.thip.vote.application.port.in.dto.VoteCreateCommand; +import konkuk.thip.vote.application.port.out.VoteCommandPort; +import konkuk.thip.vote.domain.Vote; +import konkuk.thip.vote.domain.VoteItem; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class VoteCreateService implements VoteCreateUseCase { + + private final VoteCommandPort voteCommandPort; + private final RoomCommandPort roomCommandPort; + private final BookCommandPort bookCommandPort; + + @Transactional + @Override + public Long createVote(VoteCreateCommand command) { + // 1. validate + Vote vote = Vote.withoutId( + command.content(), + command.userId(), + command.page(), + command.isOverview(), + command.roomId() + ); + + validateVote(vote); + + // 2. vote 저장 + Long savedVoteId = voteCommandPort.saveVote(vote); + + // 3. vote item 저장 + List voteItems = command.voteItemCreateCommands().stream() + .map(itemCmd -> VoteItem.withoutId( + itemCmd.itemName(), + 0, + savedVoteId + )) + .toList(); + voteCommandPort.saveAllVoteItems(voteItems); + + return savedVoteId; + } + + private void validateVote(Vote vote) { + Room room = roomCommandPort.findById(vote.getRoomId()); + Book book = bookCommandPort.findById(room.getBookId()); + + // 페이지 유효성 검증 + vote.validatePage(book.getPageCount()); + + // 총평 유효성 검증 + vote.validateOverview(book.getPageCount()); + } +} diff --git a/src/main/java/konkuk/thip/vote/application/service/VoteService.java b/src/main/java/konkuk/thip/vote/application/service/VoteService.java deleted file mode 100644 index 8a615b289..000000000 --- a/src/main/java/konkuk/thip/vote/application/service/VoteService.java +++ /dev/null @@ -1,11 +0,0 @@ -package konkuk.thip.vote.application.service; - -import konkuk.thip.vote.application.port.in.DummyUseCase; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; - -@Service -@RequiredArgsConstructor -public class VoteService implements DummyUseCase { - -} diff --git a/src/main/java/konkuk/thip/vote/domain/Vote.java b/src/main/java/konkuk/thip/vote/domain/Vote.java index 9bc9f563a..6cde37e24 100644 --- a/src/main/java/konkuk/thip/vote/domain/Vote.java +++ b/src/main/java/konkuk/thip/vote/domain/Vote.java @@ -1,9 +1,12 @@ package konkuk.thip.vote.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.InvalidStateException; import lombok.Getter; import lombok.experimental.SuperBuilder; +import static konkuk.thip.common.exception.code.ErrorCode.*; + @Getter @SuperBuilder public class Vote extends BaseDomainEntity { @@ -19,4 +22,38 @@ public class Vote extends BaseDomainEntity { private boolean isOverview; private Long roomId; + + public static Vote withoutId(String content, Long creatorId, Integer page, boolean isOverview, Long roomId) { + return Vote.builder() + .id(null) + .content(content) + .creatorId(creatorId) + .page(page) + .isOverview(isOverview) + .roomId(roomId) + .build(); + } + + public void validateOverview(int totalPageCount) { + double ratio = (double) page / totalPageCount; + if (isOverview && ratio < 0.8) { + String message = String.format( + "총평(isOverview)은 진행률이 80%% 이상일 때만 가능합니다. 현재 진행률 = %.2f%% (%d/%d)", + ratio * 100, page, totalPageCount + ); + throw new InvalidStateException(VOTE_CANNOT_BE_OVERVIEW, new IllegalStateException(message)); + } + } + + public void validatePage(int totalPageCount) { + if (page < 1 || page > totalPageCount) { + String message = String.format( + "페이지 범위가 잘못되었습니다. 현재 기록할 page = %d, 책 전체 page = %d", + page, totalPageCount + ); + throw new InvalidStateException(INVALID_VOTE_PAGE_RANGE, + new IllegalArgumentException(message) + ); + } + } } diff --git a/src/main/java/konkuk/thip/vote/domain/VoteItem.java b/src/main/java/konkuk/thip/vote/domain/VoteItem.java index e10e2dbf2..184cc09fc 100644 --- a/src/main/java/konkuk/thip/vote/domain/VoteItem.java +++ b/src/main/java/konkuk/thip/vote/domain/VoteItem.java @@ -15,4 +15,13 @@ public class VoteItem extends BaseDomainEntity { private int count; private Long voteId; + + public static VoteItem withoutId(String itemName, int count, Long voteId) { + return VoteItem.builder() + .id(null) + .itemName(itemName) + .count(count) + .voteId(voteId) + .build(); + } } diff --git a/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java b/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java new file mode 100644 index 000000000..76d4c7648 --- /dev/null +++ b/src/test/java/konkuk/thip/vote/adapter/in/web/VoteCreateControllerTest.java @@ -0,0 +1,353 @@ +package konkuk.thip.vote.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 konkuk.thip.vote.adapter.in.web.request.VoteCreateRequest; +import konkuk.thip.vote.adapter.out.jpa.VoteItemJpaEntity; +import konkuk.thip.vote.adapter.out.jpa.VoteJpaEntity; +import konkuk.thip.vote.adapter.out.persistence.VoteItemJpaRepository; +import konkuk.thip.vote.adapter.out.persistence.VoteJpaRepository; +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.util.List; +import java.util.Map; + + +import static konkuk.thip.common.exception.code.ErrorCode.API_INVALID_PARAM; +import static org.assertj.core.api.Assertions.assertThat; +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) +class VoteCreateControllerTest { + + @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; + + @Autowired + private VoteJpaRepository voteJpaRepository; + + @Autowired + private VoteItemJpaRepository voteItemJpaRepository; + + @AfterEach + void tearDown() { + voteItemJpaRepository.deleteAll(); + voteJpaRepository.deleteAll(); + roomJpaRepository.deleteAll(); + bookJpaRepository.deleteAll(); + userJpaRepository.deleteAll(); + categoryJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + private void saveUserAndRoom() { + 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() + .title("작별하지 않는다") + .isbn("9788954682152") + .authorName("한강") + .bestSeller(false) + .publisher("문학동네") + .imageUrl("https://image1.jpg") + .pageCount(300) + .description("한강의 소설") + .build()); + + + CategoryJpaEntity category = categoryJpaRepository.save(CategoryJpaEntity.builder() + .value("소설") + .aliasForCategoryJpaEntity(alias) + .build()); + + RoomJpaEntity room = roomJpaRepository.save(RoomJpaEntity.builder() + .title("한강 독서모임") + .description("한강 작품 읽기 모임") + .isPublic(true) + .roomPercentage(0.0) + .startDate(LocalDate.now().plusDays(2)) + .endDate(LocalDate.now().plusDays(30)) + .recruitCount(10) + .bookJpaEntity(book) + .categoryJpaEntity(category) + .build()); + } + + @Test + @DisplayName("[페이지 넘버, 총평 여부, 투표 내용, List<투표 항목>] 을 받아, 투표를 생성한다.") + void vote_create_success() throws Exception { + //given : user, room, request 생성 + saveUserAndRoom(); + + int page = 10; + boolean isOverview = false; + String content = "투표 내용 입니다."; + List voteItems = List.of( + new VoteCreateRequest.VoteItemCreateRequest("찬성"), + new VoteCreateRequest.VoteItemCreateRequest("반대") + ); + + VoteCreateRequest request = new VoteCreateRequest( + page, isOverview, content, voteItems + ); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + Long roomId = roomJpaRepository.findAll().get(0).getRoomId(); + + //when : 투표 생성 api 호출 (filter 통과 없이) + ResultActions result = mockMvc.perform(post("/rooms/{roomId}/vote", roomId) + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request) + )); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.voteId").exists()); + + String json = result.andReturn().getResponse().getContentAsString(); + JsonNode jsonNode = objectMapper.readTree(json); + Long voteId = jsonNode.path("data").path("voteId").asLong(); + + VoteJpaEntity voteJpaEntity = voteJpaRepository.findById(voteId).orElse(null); + List voteItemJpaEntityList = voteItemJpaRepository.findAllByVoteJpaEntity_PostId(voteJpaEntity.getPostId()); + + assertThat(voteJpaEntity.getUserJpaEntity().getUserId()).isEqualTo(userId); + assertThat(voteJpaEntity.getRoomJpaEntity().getRoomId()).isEqualTo(roomId); + assertThat(voteJpaEntity.getPage()).isEqualTo(page); + assertThat(voteJpaEntity.getContent()).isEqualTo(content); + + assertThat(voteItemJpaEntityList).hasSize(2) + .extracting(VoteItemJpaEntity::getItemName) + .containsExactlyInAnyOrder("찬성", "반대"); + } + + @Test + @DisplayName("[page]가 누락되었을 때 400 Bad Request 반환") + void vote_create_page_null() throws Exception { + // given: page 누락 + Map request = Map.of( + "isOverview", false, + "content", "내용", + "voteItemList", List.of(Map.of("itemName", "찬성")) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("page는 필수입니다."))); + } + + @Test + @DisplayName("[isOverview]가 누락되었을 때 400 Bad Request 반환") + void vote_create_is_over_view_null() throws Exception { + // given: isOverview 누락 + Map request = Map.of( + "page", 1, + "content", "내용", + "voteItemList", List.of(Map.of("itemName", "찬성")) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("isOverview(= 총평 여부)는 필수입니다."))); + } + + @Test + @DisplayName("[content]가 빈 문자열일 때 400 Bad Request 반환") + void vote_create_content_blank() throws Exception { + // given + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", "", + "voteItemList", List.of(Map.of("itemName", "찬성")) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 내용은 필수입니다."))); + } + + @Test + @DisplayName("[content]가 20자 초과일 때 400 Bad Request 반환") + void vote_create_content_too_long() throws Exception { + // given + String longContent = "가".repeat(21); + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", longContent, + "voteItemList", List.of(Map.of("itemName", "찬성")) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 내용은 최대 20자 입니다."))); + } + + @Test + @DisplayName("[voteItemList]가 누락되었을 때 400 Bad Request 반환") + void vote_create_vote_item_null() throws Exception { + // given + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", "내용" + // voteItemList 생략 + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 항목은 필수입니다."))); + } + + @Test + @DisplayName("[voteItemList]가 5개 초과일 때 400 Bad Request 반환") + void vote_create_vote_item_too_many() throws Exception { + // given: 6개 아이템 + List> items = List.of( + Map.of("itemName","A"), Map.of("itemName","B"), + Map.of("itemName","C"), Map.of("itemName","D"), + Map.of("itemName","E"), Map.of("itemName","F") + ); + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", "내용", + "voteItemList", items + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 항목은 1개 이상, 최대 5개까지입니다."))); + } + + @Test + @DisplayName("[voteItemList] 내 [itemName]이 빈 문자열일 때 400 Bad Request 반환") + void vote_create_vote_item_name_blank() throws Exception { + // given + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", "내용", + "voteItemList", List.of(Map.of("itemName","")) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 항목 이름은 필수입니다."))); + } + + @Test + @DisplayName("[voteItemList] 내 [itemName]이 20자 초과일 때 400 Bad Request 반환") + void vote_create_vote_item_name_too_long() throws Exception { + // given + String longName = "가".repeat(21); + Map request = Map.of( + "page", 1, + "isOverview", false, + "content", "내용", + "voteItemList", List.of(Map.of("itemName", longName)) + ); + + // when & then + mockMvc.perform(post("/rooms/{roomId}/vote", 1L) + .requestAttr("userId", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(API_INVALID_PARAM.getCode())) + .andExpect(jsonPath("$.message", containsString("투표 항목 이름은 최대 20자입니다."))); + } +} \ No newline at end of file diff --git a/src/test/java/konkuk/thip/vote/domain/VoteTest.java b/src/test/java/konkuk/thip/vote/domain/VoteTest.java new file mode 100644 index 000000000..49b0e1366 --- /dev/null +++ b/src/test/java/konkuk/thip/vote/domain/VoteTest.java @@ -0,0 +1,62 @@ +package konkuk.thip.vote.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class VoteTest { + + @Test + @DisplayName("validatePage: 유효한 페이지 범위일 때, 예외가 발생하지 않는다.") + void validate_page_valid_range() { + Vote vote = Vote.withoutId("content", 1L, 10, false, 1L); + assertDoesNotThrow(() -> vote.validatePage(20)); + assertDoesNotThrow(() -> vote.validatePage(10)); // 경계값 + } + + @Test + @DisplayName("validatePage: page가 1보다 작을 때, InvalidStateException 발생한다.") + void validate_page_lower_than_zero() { + Vote vote = Vote.withoutId("content", 1L, 0, false, 1L); + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> vote.validatePage(20)); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("현재 기록할 page = 0, 책 전체 page = 20")); + } + + @Test + @DisplayName("validatePage: page가 전체 페이지 수를 초과할 때, InvalidStateException 발생한다.") + void validate_page_bigger_than_total() { + Vote vote = Vote.withoutId("content", 1L, 25, false, 1L); + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> vote.validatePage(20)); + assertInstanceOf(IllegalArgumentException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("현재 기록할 page = 25, 책 전체 page = 20")); + } + + @Test + @DisplayName("validateOverview: isOverview=false 이면, 예외가 발생하지 않는다.") + void validate_overview_not_overview_no_exception() { + Vote vote = Vote.withoutId("content", 1L, 5, false, 1L); + assertDoesNotThrow(() -> vote.validateOverview(20)); + } + + @Test + @DisplayName("validateOverview: 진행률 80% 이상이고 isOverview=true 이면, 예외가 발생하지 않는다.") + void validate_overview_ratio_at_least_80_percent() { + Vote vote = Vote.withoutId("content", 1L, 16, true, 1L); + assertDoesNotThrow(() -> vote.validateOverview(20)); + } + + @Test + @DisplayName("validateOverview: 진행률 80% 미만이고 isOverview=true 이면, InvalidStateException 발생한다.") + void validate_overview_ratio_below_80_percent() { + Vote vote = Vote.withoutId("content", 1L, 15, true, 1L); // 15/20 = 0.75 + InvalidStateException ex = assertThrows(InvalidStateException.class, + () -> vote.validateOverview(20)); + assertInstanceOf(IllegalStateException.class, ex.getCause()); + assertTrue(ex.getCause().getMessage().contains("현재 진행률 = 75.00% (15/20)")); + } +}