diff --git a/src/main/java/ject/componote/domain/announcement/api/FAQController.java b/src/main/java/ject/componote/domain/announcement/api/FAQController.java deleted file mode 100644 index 0d65f264..00000000 --- a/src/main/java/ject/componote/domain/announcement/api/FAQController.java +++ /dev/null @@ -1,4 +0,0 @@ -package ject.componote.domain.announcement.api; - -public class FAQController { -} diff --git a/src/main/java/ject/componote/domain/announcement/api/NoticeController.java b/src/main/java/ject/componote/domain/announcement/api/NoticeController.java deleted file mode 100644 index 520cbb2a..00000000 --- a/src/main/java/ject/componote/domain/announcement/api/NoticeController.java +++ /dev/null @@ -1,4 +0,0 @@ -package ject.componote.domain.announcement.api; - -public class NoticeController { -} diff --git a/src/main/java/ject/componote/domain/announcement/domain/FAQRepository.java b/src/main/java/ject/componote/domain/announcement/domain/FAQRepository.java deleted file mode 100644 index c6a0e3ee..00000000 --- a/src/main/java/ject/componote/domain/announcement/domain/FAQRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package ject.componote.domain.announcement.domain; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface FAQRepository extends JpaRepository { -} diff --git a/src/main/java/ject/componote/domain/announcement/error/FAQException.java b/src/main/java/ject/componote/domain/announcement/error/FAQException.java deleted file mode 100644 index 700651e9..00000000 --- a/src/main/java/ject/componote/domain/announcement/error/FAQException.java +++ /dev/null @@ -1,4 +0,0 @@ -package ject.componote.domain.announcement.error; - -public class FAQException { -} diff --git a/src/main/java/ject/componote/domain/announcement/error/NoticeException.java b/src/main/java/ject/componote/domain/announcement/error/NoticeException.java deleted file mode 100644 index 7c4924f6..00000000 --- a/src/main/java/ject/componote/domain/announcement/error/NoticeException.java +++ /dev/null @@ -1,4 +0,0 @@ -package ject.componote.domain.announcement.error; - -public class NoticeException { -} diff --git a/src/main/java/ject/componote/domain/announcement/model/Description.java b/src/main/java/ject/componote/domain/announcement/model/Description.java deleted file mode 100644 index 2c5bdcc2..00000000 --- a/src/main/java/ject/componote/domain/announcement/model/Description.java +++ /dev/null @@ -1,20 +0,0 @@ -package ject.componote.domain.announcement.model; - -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; - -@EqualsAndHashCode -@Getter -@ToString -public class Description { - private final String value; - - private Description(final String value) { - this.value = value; - } - - public static Description from(final String value) { - return new Description(value); - } -} diff --git a/src/main/java/ject/componote/domain/announcement/model/Title.java b/src/main/java/ject/componote/domain/announcement/model/Title.java deleted file mode 100644 index e9da9c4f..00000000 --- a/src/main/java/ject/componote/domain/announcement/model/Title.java +++ /dev/null @@ -1,20 +0,0 @@ -package ject.componote.domain.announcement.model; - -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; - -@EqualsAndHashCode -@Getter -@ToString -public class Title { - private final String value; - - private Title(final String value) { - this.value = value; - } - - public static Title from(final String value) { - return new Title(value); - } -} diff --git a/src/main/java/ject/componote/domain/announcement/model/converter/DescriptionConverter.java b/src/main/java/ject/componote/domain/announcement/model/converter/DescriptionConverter.java deleted file mode 100644 index 7fcaa55c..00000000 --- a/src/main/java/ject/componote/domain/announcement/model/converter/DescriptionConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -package ject.componote.domain.announcement.model.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import ject.componote.domain.announcement.model.Description; - -@Converter -public class DescriptionConverter implements AttributeConverter { - @Override - public String convertToDatabaseColumn(final Description attribute) { - return attribute.getValue(); - } - - @Override - public Description convertToEntityAttribute(final String dbData) { - return Description.from(dbData); - } -} diff --git a/src/main/java/ject/componote/domain/announcement/model/converter/TitleConverter.java b/src/main/java/ject/componote/domain/announcement/model/converter/TitleConverter.java deleted file mode 100644 index d6c9920b..00000000 --- a/src/main/java/ject/componote/domain/announcement/model/converter/TitleConverter.java +++ /dev/null @@ -1,18 +0,0 @@ -package ject.componote.domain.announcement.model.converter; - -import jakarta.persistence.AttributeConverter; -import jakarta.persistence.Converter; -import ject.componote.domain.announcement.model.Title; - -@Converter -public class TitleConverter implements AttributeConverter { - @Override - public String convertToDatabaseColumn(final Title attribute) { - return attribute.getValue(); - } - - @Override - public Title convertToEntityAttribute(final String dbData) { - return Title.from(dbData); - } -} diff --git a/src/main/java/ject/componote/domain/auth/application/AuthService.java b/src/main/java/ject/componote/domain/auth/application/AuthService.java index 9ae42217..cca2b0bc 100644 --- a/src/main/java/ject/componote/domain/auth/application/AuthService.java +++ b/src/main/java/ject/componote/domain/auth/application/AuthService.java @@ -39,7 +39,7 @@ public MemberSignupResponse signup(final MemberSignupRequest request) { } final Member member = memberRepository.save(request.toMember()); - fileService.moveImage(member.getProfileImage().getImage()); // 맘에 안듬... + fileService.moveImage(member.getProfileImage()); return MemberSignupResponse.from(member); } diff --git a/src/main/java/ject/componote/domain/auth/application/MemberService.java b/src/main/java/ject/componote/domain/auth/application/MemberService.java index 7f9d1a95..ae2cf499 100644 --- a/src/main/java/ject/componote/domain/auth/application/MemberService.java +++ b/src/main/java/ject/componote/domain/auth/application/MemberService.java @@ -37,7 +37,7 @@ public void updateProfileImage(final AuthPrincipal authPrincipal, final MemberPr return; } - fileService.moveImage(profileImage.getImage()); + fileService.moveImage(profileImage); member.updateProfileImage(profileImage); memberRepository.save(member); } diff --git a/src/main/java/ject/componote/domain/auth/dto/find/response/MemberSummaryResponse.java b/src/main/java/ject/componote/domain/auth/dto/find/response/MemberSummaryResponse.java index a9d8d779..867205a6 100644 --- a/src/main/java/ject/componote/domain/auth/dto/find/response/MemberSummaryResponse.java +++ b/src/main/java/ject/componote/domain/auth/dto/find/response/MemberSummaryResponse.java @@ -6,7 +6,7 @@ public record MemberSummaryResponse(String nickname, String profileImageUrl) { public static MemberSummaryResponse from(MemberSummaryDao memberSummaryDao) { return new MemberSummaryResponse( memberSummaryDao.nickname().getValue(), - memberSummaryDao.profileImage().getImage().toUrl() + memberSummaryDao.profileImage().toUrl() ); } } diff --git a/src/main/java/ject/componote/domain/auth/model/ProfileImage.java b/src/main/java/ject/componote/domain/auth/model/ProfileImage.java index 0c7437b7..11b588e0 100644 --- a/src/main/java/ject/componote/domain/auth/model/ProfileImage.java +++ b/src/main/java/ject/componote/domain/auth/model/ProfileImage.java @@ -1,26 +1,22 @@ package ject.componote.domain.auth.model; import ject.componote.domain.auth.error.InvalidProfileImageExtensionException; -import ject.componote.domain.common.model.BaseImage; +import ject.componote.domain.common.model.AbstractImage; import lombok.EqualsAndHashCode; -import lombok.Getter; import lombok.ToString; import org.springframework.util.StringUtils; import java.util.Arrays; import java.util.List; -@EqualsAndHashCode -@Getter +@EqualsAndHashCode(callSuper = true) @ToString -public class ProfileImage { +public class ProfileImage extends AbstractImage { private static final List ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png"); private static final ProfileImage DEFAULT_PROFILE_IMAGE = ProfileImage.from("/profiles/default-profile-image.png"); - private final BaseImage image; - - private ProfileImage(final BaseImage image) { - this.image = image; + public ProfileImage(final String objectKey) { + super(objectKey); } public static ProfileImage from(final String objectKey) { @@ -33,6 +29,6 @@ public static ProfileImage from(final String objectKey) { throw new InvalidProfileImageExtensionException(extension); } - return new ProfileImage(BaseImage.from(objectKey)); + return new ProfileImage(objectKey); } } diff --git a/src/main/java/ject/componote/domain/auth/model/converter/ProfileImageConverter.java b/src/main/java/ject/componote/domain/auth/model/converter/ProfileImageConverter.java index 0e9c8660..83264e4b 100644 --- a/src/main/java/ject/componote/domain/auth/model/converter/ProfileImageConverter.java +++ b/src/main/java/ject/componote/domain/auth/model/converter/ProfileImageConverter.java @@ -8,8 +8,7 @@ public class ProfileImageConverter implements AttributeConverter { @Override public String convertToDatabaseColumn(final ProfileImage attribute) { - return attribute.getImage() - .getObjectKey(); + return attribute.getObjectKey(); } @Override diff --git a/src/main/java/ject/componote/domain/bookmark/dao/BookmarkRepository.java b/src/main/java/ject/componote/domain/bookmark/dao/BookmarkRepository.java new file mode 100644 index 00000000..9215cf2f --- /dev/null +++ b/src/main/java/ject/componote/domain/bookmark/dao/BookmarkRepository.java @@ -0,0 +1,8 @@ +package ject.componote.domain.bookmark.dao; + +import ject.componote.domain.bookmark.domain.Bookmark; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BookmarkRepository extends JpaRepository { + boolean existsByComponentIdAndMemberId(final Long componentId, final Long memberId); +} diff --git a/src/main/java/ject/componote/domain/comment/api/CommentController.java b/src/main/java/ject/componote/domain/comment/api/CommentController.java index b4713ed1..0e75b4d3 100644 --- a/src/main/java/ject/componote/domain/comment/api/CommentController.java +++ b/src/main/java/ject/componote/domain/comment/api/CommentController.java @@ -1,12 +1,111 @@ package ject.componote.domain.comment.api; +import jakarta.validation.Valid; +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.auth.model.Authenticated; +import ject.componote.domain.auth.model.User; +import ject.componote.domain.comment.application.CommentService; +import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; +import ject.componote.domain.comment.dto.create.response.CommentCreateResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByComponentResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByMemberResponse; +import ject.componote.domain.comment.dto.update.request.CommentUpdateRequest; +import ject.componote.domain.common.dto.response.PageResponse; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; -@RequestMapping("/comments") @RequiredArgsConstructor @RestController public class CommentController { + private static final int DEFAULT_MEMBER_COMMENT_PAGE_SIZE = 8; + private static final int DEFAULT_COMPONENT_COMMENT_PAGE_SIZE = 5; + private static final int DEFAULT_REPLY_PAGE_SIZE = 5; + private final CommentService commentService; + + @PostMapping("/comments") + @User + public ResponseEntity create( + @Authenticated final AuthPrincipal authPrincipal, + @RequestBody @Valid final CommentCreateRequest commentCreateRequest + ) { + final CommentCreateResponse commentCreateResponse = commentService.create(authPrincipal, commentCreateRequest); + return ResponseEntity.ok(commentCreateResponse); + } + + @GetMapping("/members/comments") + @User + public ResponseEntity> getCommentsByMemberId( + @Authenticated final AuthPrincipal authPrincipal, + @PageableDefault(size = DEFAULT_MEMBER_COMMENT_PAGE_SIZE) final Pageable pageable + ) { + final PageResponse pageResponse = commentService.getCommentsByMemberId(authPrincipal, pageable); + return ResponseEntity.ok(pageResponse); + } + + @GetMapping("/components/{componentId}/comments") + public ResponseEntity> getCommentsByComponentId( + @Authenticated final AuthPrincipal authPrincipal, + @PathVariable("componentId") final Long componentId, + @PageableDefault(size = DEFAULT_COMPONENT_COMMENT_PAGE_SIZE) final Pageable pageable + ) { + final PageResponse pageResponse = commentService.getCommentsByComponentId(authPrincipal, componentId, pageable); + return ResponseEntity.ok(pageResponse); + } + + @GetMapping("/comments/{parentId}/replies") + public ResponseEntity getRepliesByCommentId( + @Authenticated final AuthPrincipal authPrincipal, + @PathVariable("parentId") final Long parentId, + @PageableDefault(size = DEFAULT_REPLY_PAGE_SIZE) final Pageable pageable + ) { + final PageResponse pageResponse = commentService.getRepliesByComponentId(authPrincipal, parentId, pageable); + return ResponseEntity.ok(pageResponse); + } + + @PutMapping("/comments/{commentId}") + @User + public ResponseEntity update(@Authenticated final AuthPrincipal authPrincipal, + @PathVariable("commentId") final Long commentId, + @RequestBody @Valid final CommentUpdateRequest commentUpdateRequest) { + commentService.update(authPrincipal, commentId, commentUpdateRequest); + return ResponseEntity.noContent() + .build(); + } + + @PostMapping("/comments/{commentId}/likes") + @User + public ResponseEntity likeComment(@Authenticated final AuthPrincipal authPrincipal, + @PathVariable("commentId") final Long commentId) { + commentService.likeComment(authPrincipal, commentId); + return ResponseEntity.noContent() + .build(); + } + + @DeleteMapping("/comments/{commentId}/likes") + @User + public ResponseEntity unlikeComment(@Authenticated final AuthPrincipal authPrincipal, + @PathVariable("commentId") final Long commentId) { + commentService.unlikeComment(authPrincipal, commentId); + return ResponseEntity.noContent() + .build(); + } + + @DeleteMapping("/comments/{commentId}") + @User + public ResponseEntity delete(@Authenticated final AuthPrincipal authPrincipal, + @PathVariable("commentId") final Long commentId) { + commentService.delete(authPrincipal, commentId); + return ResponseEntity.noContent() + .build(); + } } diff --git a/src/main/java/ject/componote/domain/comment/application/CommentCreationStrategy.java b/src/main/java/ject/componote/domain/comment/application/CommentCreationStrategy.java new file mode 100644 index 00000000..4d7c2c1b --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/application/CommentCreationStrategy.java @@ -0,0 +1,49 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; +import ject.componote.domain.comment.error.InvalidCommentCreateStrategyException; +import lombok.RequiredArgsConstructor; + +import java.util.Arrays; +import java.util.function.Predicate; + +@RequiredArgsConstructor +public enum CommentCreationStrategy { + GENERAL_WITHOUT_IMAGE( + request -> request.parentId() == null && request.imageObjectKey() == null, + (request, memberId) -> + Comment.createWithoutImage(request.componentId(), memberId, request.content()) + ), + GENERAL_WITH_IMAGE( + request -> request.parentId() == null && request.imageObjectKey() != null, + (request, memberId) -> + Comment.createWithImage(request.componentId(), memberId, request.content(), request.imageObjectKey()) + ), + REPLY_WITHOUT_IMAGE( + request -> request.parentId() != null && request.imageObjectKey() == null, + (request, memberId) -> + Comment.createReplyWithoutImage(request.componentId(), memberId, request.parentId(), request.content()) + ), + REPLY_WITH_IMAGE( + request -> request.parentId() != null && request.imageObjectKey() != null, + (request, memberId) -> + Comment.createReplyWithImage(request.componentId(), memberId, request.parentId(), request.content(), request.imageObjectKey()) + ); + + private final Predicate condition; + private final CommentCreationFunction creationFunction; + + public static Comment createBy(final CommentCreateRequest request, final Long memberId) { + return Arrays.stream(values()) + .filter(type -> type.condition.test(request)) + .findFirst() + .orElseThrow(InvalidCommentCreateStrategyException::new) + .creationFunction.create(request, memberId); + } + + @FunctionalInterface + private interface CommentCreationFunction { + Comment create(final CommentCreateRequest request, final Long memberId); + } +} diff --git a/src/main/java/ject/componote/domain/comment/application/CommentLikeEventListener.java b/src/main/java/ject/componote/domain/comment/application/CommentLikeEventListener.java new file mode 100644 index 00000000..b4fec937 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/application/CommentLikeEventListener.java @@ -0,0 +1,53 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.domain.CommentLike; +import ject.componote.domain.comment.dao.CommentLikeRepository; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.dto.like.event.CommentLikeEvent; +import ject.componote.domain.comment.dto.like.event.CommentUnlikeEvent; +import ject.componote.domain.comment.error.NotFoundCommentException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentLikeEventListener { + private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + + @Async + @EventListener + @Transactional + public void handleCommentLikeEvent(final CommentLikeEvent event) { + final Long commentId = event.commentId(); + final Comment comment = findCommentById(commentId); + comment.increaseLikeCount(); + + final Long memberId = event.memberId(); + commentLikeRepository.save(CommentLike.of(commentId, memberId)); + commentRepository.save(comment); + } + + @Async + @EventListener + @Transactional + public void handleCommentUnlikeEvent(final CommentUnlikeEvent event) { + final Long commentId = event.commentId(); + final Comment comment = findCommentById(commentId); + comment.decreaseLikeCount(); + + final Long memberId = event.memberId(); + commentLikeRepository.deleteByCommentIdAndMemberId(commentId, memberId); + commentRepository.save(comment); + } + + private Comment findCommentById(final Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundCommentException(commentId)); + } +} diff --git a/src/main/java/ject/componote/domain/comment/application/CommentReplyCountEventHandler.java b/src/main/java/ject/componote/domain/comment/application/CommentReplyCountEventHandler.java new file mode 100644 index 00000000..f0c18135 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/application/CommentReplyCountEventHandler.java @@ -0,0 +1,31 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.reply.event.CommentReplyCountIncreaseEvent; +import ject.componote.domain.comment.error.NotFoundCommentException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentReplyCountEventHandler { + private final CommentRepository commentRepository; + + @Async + @EventListener + @Transactional + public void handleCommentReplyCountIncreaseEvent(final CommentReplyCountIncreaseEvent event) { + final Comment comment = findCommentById(event.parentId()); + comment.increaseReplyCount(); + } + + public Comment findCommentById(final Long commentId) { + return commentRepository.findById(commentId) + .orElseThrow(() -> new NotFoundCommentException(commentId)); + } +} diff --git a/src/main/java/ject/componote/domain/comment/application/CommentService.java b/src/main/java/ject/componote/domain/comment/application/CommentService.java index 9cd5bdf4..d8743f5c 100644 --- a/src/main/java/ject/componote/domain/comment/application/CommentService.java +++ b/src/main/java/ject/componote/domain/comment/application/CommentService.java @@ -1,20 +1,169 @@ package ject.componote.domain.comment.application; import ject.componote.domain.auth.model.AuthPrincipal; -import ject.componote.domain.comment.domain.CommentRepository; +import ject.componote.domain.comment.dao.CommentFindByComponentDao; +import ject.componote.domain.comment.dao.CommentFindByParentDao; +import ject.componote.domain.comment.dao.CommentLikeRepository; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.domain.Comment; import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; import ject.componote.domain.comment.dto.create.response.CommentCreateResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByComponentResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByMemberResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByParentResponse; +import ject.componote.domain.comment.dto.like.event.CommentLikeEvent; +import ject.componote.domain.comment.dto.like.event.CommentUnlikeEvent; +import ject.componote.domain.comment.dto.reply.event.CommentReplyCountIncreaseEvent; +import ject.componote.domain.comment.dto.update.request.CommentUpdateRequest; +import ject.componote.domain.comment.error.AlreadyLikedException; +import ject.componote.domain.comment.error.NoLikedException; +import ject.componote.domain.comment.error.NotFoundCommentException; +import ject.componote.domain.comment.error.NotFoundParentCommentException; +import ject.componote.domain.comment.model.CommentContent; +import ject.componote.domain.comment.model.CommentImage; +import ject.componote.domain.comment.validation.CommenterValidation; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.infra.file.application.FileService; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @RequiredArgsConstructor @Service +@Transactional(readOnly = true) public class CommentService { + private final ApplicationEventPublisher eventPublisher; private final CommentRepository commentRepository; + private final CommentLikeRepository commentLikeRepository; + private final FileService fileService; - // 비동기로? - public CommentCreateResponse create(final AuthPrincipal authPrincipal, - final CommentCreateRequest request) { - return null; + @Transactional + public CommentCreateResponse create(final AuthPrincipal authPrincipal, final CommentCreateRequest request) { + validateParentId(request); + final Comment comment = commentRepository.save( + CommentCreationStrategy.createBy(request, authPrincipal.id()) + ); + increaseParentReplyCount(request); + fileService.moveImage(comment.getImage()); + return CommentCreateResponse.from(comment); + } + + public PageResponse getCommentsByMemberId(final AuthPrincipal authPrincipal, + final Pageable pageable) { + final Page page = commentRepository.findAllByMemberIdWithPagination(authPrincipal.id(), pageable) + .map(CommentFindByMemberResponse::from); + return PageResponse.from(page); + } + + public PageResponse getCommentsByComponentId(final AuthPrincipal authPrincipal, + final Long componentId, + final Pageable pageable) { + final Page page = findCommentsByComponentId(authPrincipal, componentId, pageable) + .map(CommentFindByComponentResponse::from); + return PageResponse.from(page); + } + + public PageResponse getRepliesByComponentId(final AuthPrincipal authPrincipal, + final Long parentId, + final Pageable pageable) { + final Page page = findCommentsByParentId(authPrincipal, parentId, pageable) + .map(CommentFindByParentResponse::from); + return PageResponse.from(page); + } + + @CommenterValidation + @Transactional + public void update(final AuthPrincipal authPrincipal, final Long commentId, final CommentUpdateRequest commentUpdateRequest) { + final Comment comment = findCommentByIdAndMemberId(commentId, authPrincipal.id()); + + final CommentImage image = CommentImage.from(commentUpdateRequest.imageObjectKey()); + if (!comment.equalsImage(image)) { + fileService.moveImage(image); + } + + final CommentContent content = CommentContent.from(commentUpdateRequest.content()); // 가비지가 발생하지 않을까? + comment.update(content, image); + + commentRepository.save(comment); + } + + @CommenterValidation + @Transactional + public void delete(final AuthPrincipal authPrincipal, final Long commentId) { + commentRepository.deleteByIdAndMemberId(commentId, authPrincipal.id()); + } + + public void likeComment(final AuthPrincipal authPrincipal, final Long commentId) { + final Long memberId = authPrincipal.id(); + if (isAlreadyLiked(commentId, memberId)) { + throw new AlreadyLikedException(commentId, memberId); + } + + eventPublisher.publishEvent(CommentLikeEvent.of(authPrincipal, commentId)); + } + + public void unlikeComment(final AuthPrincipal authPrincipal, final Long commentId) { + final Long memberId = authPrincipal.id(); + if (!isAlreadyLiked(commentId, memberId)) { + throw new NoLikedException(commentId, memberId); + } + + eventPublisher.publishEvent(CommentUnlikeEvent.of(authPrincipal, commentId)); + } + + private Comment findCommentByIdAndMemberId(final Long commentId, final Long memberId) { + return commentRepository.findByIdAndMemberId(commentId, memberId) + .orElseThrow(() -> new NotFoundCommentException(commentId, memberId)); + } + + private Page findCommentsByComponentId(final AuthPrincipal authPrincipal, + final Long componentId, + final Pageable pageable) { + if (authPrincipal == null) { + return commentRepository.findAllByComponentIdWithPagination(componentId, pageable); + } + + final Long memberId = authPrincipal.id(); + return commentRepository.findAllByComponentIdWithLikeStatusAndPagination(componentId, memberId, pageable); + } + + private Page findCommentsByParentId(final AuthPrincipal authPrincipal, final Long parentId, final Pageable pageable) { + if (authPrincipal == null) { + return commentRepository.findAllByParentIdWithPagination(parentId, pageable); + } + + final Long memberId = authPrincipal.id(); + return commentRepository.findAllByParentIdWithLikeStatusAndPagination(parentId, memberId, pageable); + } + + private boolean isReply(final CommentCreateRequest request) { + return request.parentId() != null; + } + + private void validateParentId(final CommentCreateRequest request) { + if (!isReply(request)) { + return; + } + + final Long parentId = request.parentId(); + if (!commentRepository.existsById(parentId)) { + throw new NotFoundParentCommentException(parentId); + } + } + + private void increaseParentReplyCount(final CommentCreateRequest request) { + if (!isReply(request)) { + return; + } + + final Long parentId = request.parentId(); + eventPublisher.publishEvent(CommentReplyCountIncreaseEvent.from(parentId)); + } + + private boolean isAlreadyLiked(final Long commentId, final Long memberId) { + return commentLikeRepository.existsByCommentIdAndMemberId(commentId, memberId); } } diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentFindByComponentDao.java b/src/main/java/ject/componote/domain/comment/dao/CommentFindByComponentDao.java new file mode 100644 index 00000000..695d18de --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentFindByComponentDao.java @@ -0,0 +1,26 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.auth.domain.Job; +import ject.componote.domain.auth.model.Nickname; +import ject.componote.domain.auth.model.ProfileImage; +import ject.componote.domain.comment.model.CommentContent; +import ject.componote.domain.comment.model.CommentImage; +import ject.componote.domain.common.model.Count; + +import java.time.LocalDateTime; + +public record CommentFindByComponentDao( + Long memberId, + Nickname nickname, + ProfileImage profileImage, + Job job, + Long commentId, + Long parentId, + CommentImage commentImage, + CommentContent content, + LocalDateTime createdAt, + Count likeCount, + Count replyCount, + boolean isLiked, + boolean isReply) { +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentFindByMemberDao.java b/src/main/java/ject/componote/domain/comment/dao/CommentFindByMemberDao.java new file mode 100644 index 00000000..649f91f1 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentFindByMemberDao.java @@ -0,0 +1,8 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.comment.model.CommentContent; + +import java.time.LocalDateTime; + +public record CommentFindByMemberDao(Long id, Long parentId, String componentTitle, CommentContent parentContent, CommentContent content, LocalDateTime createdAt, boolean isReply) { +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentFindByParentDao.java b/src/main/java/ject/componote/domain/comment/dao/CommentFindByParentDao.java new file mode 100644 index 00000000..e4b40e6a --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentFindByParentDao.java @@ -0,0 +1,23 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.auth.domain.Job; +import ject.componote.domain.auth.model.Nickname; +import ject.componote.domain.auth.model.ProfileImage; +import ject.componote.domain.comment.model.CommentContent; +import ject.componote.domain.comment.model.CommentImage; +import ject.componote.domain.common.model.Count; + +import java.time.LocalDateTime; + +public record CommentFindByParentDao( + Long memberId, + Nickname nickname, + ProfileImage profileImage, + Job job, + Long commentId, + CommentImage commentImage, + CommentContent content, + LocalDateTime createdAt, + Count likeCount, + boolean isLiked) { +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentLikeRepository.java b/src/main/java/ject/componote/domain/comment/dao/CommentLikeRepository.java new file mode 100644 index 00000000..ab2a53e3 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentLikeRepository.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.comment.domain.CommentLike; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentLikeRepository extends JpaRepository { + boolean existsByCommentIdAndMemberId(final Long commentId, final Long memberId); + void deleteByCommentIdAndMemberId(final Long commentId, final Long memberId); +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentProfileDao.java b/src/main/java/ject/componote/domain/comment/dao/CommentProfileDao.java new file mode 100644 index 00000000..53b63ceb --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentProfileDao.java @@ -0,0 +1,8 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.auth.domain.Job; +import ject.componote.domain.auth.model.Nickname; +import ject.componote.domain.common.model.BaseImage; + +public record CommentProfileDao(Long memberId, Nickname nickname, BaseImage profileImage, Job job) { +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentQueryDsl.java b/src/main/java/ject/componote/domain/comment/dao/CommentQueryDsl.java new file mode 100644 index 00000000..f8b58d8e --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentQueryDsl.java @@ -0,0 +1,12 @@ +package ject.componote.domain.comment.dao; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface CommentQueryDsl { + Page findAllByComponentIdWithPagination(final Long componentId, final Pageable pageable); + Page findAllByComponentIdWithLikeStatusAndPagination(final Long componentId, final Long memberId, final Pageable pageable); + Page findAllByParentIdWithPagination(final Long parentId, final Pageable pageable); + Page findAllByParentIdWithLikeStatusAndPagination(final Long parentId, final Long memberId, final Pageable pageable); + Page findAllByMemberIdWithPagination(final Long memberId, final Pageable pageable); +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentQueryDslImpl.java b/src/main/java/ject/componote/domain/comment/dao/CommentQueryDslImpl.java new file mode 100644 index 00000000..c67c5815 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentQueryDslImpl.java @@ -0,0 +1,88 @@ +package ject.componote.domain.comment.dao; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import ject.componote.domain.comment.domain.QComment; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import static ject.componote.domain.auth.domain.QMember.member; +import static ject.componote.domain.comment.domain.QComment.comment; +import static ject.componote.domain.comment.domain.QCommentLike.commentLike; +import static ject.componote.domain.component.domain.QComponent.component; +import static ject.componote.global.util.RepositoryUtils.eqExpression; +import static ject.componote.global.util.RepositoryUtils.toPage; + +@RequiredArgsConstructor +public class CommentQueryDslImpl implements CommentQueryDsl { + private static final QComment PARENT = new QComment("parent"); + + private final JPAQueryFactory queryFactory; + private final QCommentDaoFactory qCommentDaoFactory; + + @Override + public Page findAllByComponentIdWithPagination(final Long componentId, final Pageable pageable) { + final BooleanExpression predicate = eqExpression(comment.componentId, componentId); + final JPAQuery countQuery = createCountQuery(predicate); + final JPAQuery baseQuery = queryFactory.select(qCommentDaoFactory.createForComponent()) + .from(comment) + .innerJoin(member).on(eqExpression(member.id, comment.memberId)) + .where(predicate); + return toPage(baseQuery, countQuery, comment, pageable); + } + + @Override + public Page findAllByComponentIdWithLikeStatusAndPagination(final Long componentId, final Long memberId, final Pageable pageable) { + final BooleanExpression predicate = eqExpression(comment.componentId, componentId); + final JPAQuery countQuery = createCountQuery(predicate); + final JPAQuery baseQuery = queryFactory.select(qCommentDaoFactory.createForComponentWithLikeStatus()) + .from(comment) + .innerJoin(member).on(eqExpression(member.id, comment.memberId)) + .innerJoin(commentLike).on(eqExpression(commentLike.commentId, component.id).and(eqExpression(commentLike.memberId, memberId))) + .where(predicate); + return toPage(baseQuery, countQuery, comment, pageable); + } + + @Override + public Page findAllByParentIdWithPagination(final Long parentId, final Pageable pageable) { + final BooleanExpression predicate = eqExpression(comment.parentId, parentId); + final JPAQuery countQuery = createCountQuery(predicate); + final JPAQuery baseQuery = queryFactory.select(qCommentDaoFactory.createForParent()) + .from(comment) + .innerJoin(member).on(eqExpression(member.id, comment.memberId)) + .where(predicate); + return toPage(baseQuery, countQuery, comment, pageable); + } + + @Override + public Page findAllByParentIdWithLikeStatusAndPagination(final Long parentId, final Long memberId, final Pageable pageable) { + final BooleanExpression predicate = eqExpression(comment.parentId, parentId); + final JPAQuery countQuery = createCountQuery(predicate); + final JPAQuery baseQuery = queryFactory.select(qCommentDaoFactory.createForParent()) + .from(comment) + .innerJoin(member).on(eqExpression(member.id, comment.memberId)) + .innerJoin(commentLike).on(eqExpression(commentLike.commentId, component.id).and(eqExpression(commentLike.memberId, memberId))) + .where(predicate); + return toPage(baseQuery, countQuery, comment, pageable); + } + + @Override + public Page findAllByMemberIdWithPagination(final Long memberId, final Pageable pageable) { + final BooleanExpression predicate = eqExpression(comment.memberId, memberId); + final JPAQuery countQuery = createCountQuery(predicate); + final JPAQuery baseQuery = queryFactory.select(qCommentDaoFactory.createForMember(PARENT)) + .from(comment) + .innerJoin(PARENT).on(eqExpression(PARENT.id, comment.parentId)) + .innerJoin(component).on(eqExpression(component.id, comment.componentId)) + .where(predicate); + return toPage(baseQuery, countQuery, comment, pageable); + } + + private JPAQuery createCountQuery(final BooleanExpression predicate) { + return queryFactory.select(comment.count()) + .from(comment) + .where(predicate); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dao/CommentRepository.java b/src/main/java/ject/componote/domain/comment/dao/CommentRepository.java new file mode 100644 index 00000000..bb04504b --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/CommentRepository.java @@ -0,0 +1,12 @@ +package ject.componote.domain.comment.dao; + +import ject.componote.domain.comment.domain.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CommentRepository extends JpaRepository, CommentQueryDsl { + Optional findByIdAndMemberId(final Long id, final Long memberId); + boolean existsByIdAndMemberId(final Long id, final Long memberId); + void deleteByIdAndMemberId(final Long commentId, final Long memberId); +} diff --git a/src/main/java/ject/componote/domain/comment/dao/QCommentDaoFactory.java b/src/main/java/ject/componote/domain/comment/dao/QCommentDaoFactory.java new file mode 100644 index 00000000..e248fa3c --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dao/QCommentDaoFactory.java @@ -0,0 +1,98 @@ +package ject.componote.domain.comment.dao; + +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import ject.componote.domain.comment.domain.QComment; +import org.springframework.stereotype.Component; + +import static ject.componote.domain.auth.domain.QMember.member; +import static ject.componote.domain.comment.domain.QComment.comment; +import static ject.componote.domain.comment.domain.QCommentLike.commentLike; +import static ject.componote.domain.component.domain.QComponent.component; + +@Component +public class QCommentDaoFactory { + public ConstructorExpression createForComponent() { + return Projections.constructor( + CommentFindByComponentDao.class, + member.id, + member.nickname, + member.profileImage, + member.job, + comment.id, + comment.parentId, + comment.image, + comment.content, + comment.createdAt, + comment.likeCount, + comment.replyCount, + Expressions.asBoolean(false), + comment.parentId.isNotNull() + ); + } + + public ConstructorExpression createForComponentWithLikeStatus() { + return Projections.constructor( + CommentFindByComponentDao.class, + member.id, + member.nickname, + member.profileImage, + member.job, + comment.id, + comment.image, + comment.content, + comment.createdAt, + comment.likeCount, + comment.replyCount, + commentLike.isNotNull(), + comment.parentId.isNotNull() + ); + } + + public ConstructorExpression createForParent() { + return Projections.constructor( + CommentFindByParentDao.class, + member.id, + member.nickname, + member.profileImage, + member.job, + comment.id, + comment.image, + comment.content, + comment.createdAt, + comment.likeCount, + Expressions.asBoolean(false) + ); + } + + public ConstructorExpression createForParentWithLikeStatus() { + return Projections.constructor( + CommentFindByParentDao.class, + member.id, + member.nickname, + member.profileImage, + member.job, + comment.id, + comment.parentId, + comment.image, + comment.content, + comment.createdAt, + comment.likeCount, + commentLike.isNotNull() + ); + } + + public ConstructorExpression createForMember(final QComment parent) { + return Projections.constructor( + CommentFindByMemberDao.class, + comment.id, + comment.parentId, + component.summary.title, + parent.content, + comment.content, + comment.createdAt, + comment.parentId.isNotNull() + ); + } +} diff --git a/src/main/java/ject/componote/domain/comment/domain/Comment.java b/src/main/java/ject/componote/domain/comment/domain/Comment.java index f54df2df..a46d0a61 100644 --- a/src/main/java/ject/componote/domain/comment/domain/Comment.java +++ b/src/main/java/ject/componote/domain/comment/domain/Comment.java @@ -7,12 +7,12 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import ject.componote.domain.comment.model.CommentContent; +import ject.componote.domain.comment.model.CommentImage; import ject.componote.domain.comment.model.converter.CommentContentConverter; +import ject.componote.domain.comment.model.converter.CommentImageConverter; import ject.componote.domain.common.domain.BaseEntity; import ject.componote.domain.common.model.Count; -import ject.componote.domain.common.model.Image; import ject.componote.domain.common.model.converter.CountConverter; -import ject.componote.domain.common.model.converter.ImageConverter; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -32,8 +32,8 @@ public class Comment extends BaseEntity { private CommentContent content; @Column(name = "image", nullable = true) - @Convert(converter = ImageConverter.class) - private Image image; + @Convert(converter = CommentImageConverter.class) + private CommentImage image; @Column(name = "report_count", nullable = false) @Convert(converter = CountConverter.class) @@ -43,38 +43,72 @@ public class Comment extends BaseEntity { @Convert(converter = CountConverter.class) private Count likeCount; + @Column(name = "reply_count", nullable = false) + @Convert(converter = CountConverter.class) + private Count replyCount; + @Column(name = "component_id", nullable = false) private Long componentId; @Column(name = "member_id", nullable = false) private Long memberId; - @Column(name = "parent_id", nullable = false) + @Column(name = "parent_id", nullable = true) private Long parentId; - private Comment(final Long componentId, final Long memberId, final Long parentId, final String content, final Image image) { + private Comment(final Long componentId, final Long memberId, final Long parentId, final String content, final String objectKey) { this.componentId = componentId; this.memberId = memberId; this.parentId = parentId; this.content = CommentContent.from(content); - this.image = image; + this.image = CommentImage.from(objectKey); this.likeCount = Count.create(); this.reportCount = Count.create(); + this.replyCount = Count.create(); } - public static Comment createWithImage(final Long componentId, final Long memberId, final String content, final Image image) { - return new Comment(componentId, memberId, null, content, image); + public static Comment createWithImage(final Long componentId, final Long memberId, final String content, final String objectKey) { + return new Comment(componentId, memberId, null, content, objectKey); } - public static Comment createWithoutImage(final Long componentId, final Long memberId, final String content) { + public static Comment createWithoutImage(final Long componentId, final Long memberId, final String content) { return new Comment(componentId, memberId, null, content, null); } - public static Comment createReplyWithoutImage(final Long componentId, final Long memberId, final Comment parentComment, final String content) { - return new Comment(componentId, memberId, parentComment.getParentId(), content, null); + public static Comment createReplyWithoutImage(final Long componentId, final Long memberId, final Long parentId, final String content) { + return new Comment(componentId, memberId, parentId, content, null); + } + + public static Comment createReplyWithImage(final Long componentId, final Long memberId, final Long parentId, final String content, final String objectKey) { + return new Comment(componentId, memberId, parentId, content, objectKey); + } + + public void increaseLikeCount() { + this.likeCount.increase(); + } + + public void decreaseLikeCount() { + this.likeCount.decrease(); } - public static Comment createReplyWithImage(final Long componentId, final Long memberId, final Comment parentComment, final String content, final Image image) { - return new Comment(componentId, memberId, parentComment.getParentId(), content, image); + public void increaseReplyCount() { + this.replyCount.increase(); + } + + public boolean equalsImage(final CommentImage image) { + return this.image.equals(image); + } + + public void update(final CommentContent content, final CommentImage image) { + updateContent(content); + updateImage(image); + } + + private void updateContent(final CommentContent content) { + this.content = content; + } + + private void updateImage(final CommentImage image) { + this.image = image; } } diff --git a/src/main/java/ject/componote/domain/comment/domain/CommentLike.java b/src/main/java/ject/componote/domain/comment/domain/CommentLike.java new file mode 100644 index 00000000..261cd696 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/domain/CommentLike.java @@ -0,0 +1,36 @@ +package ject.componote.domain.comment.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString +public class CommentLike { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "comment_id", nullable = false) + private Long commentId; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + private CommentLike(final Long commentId, final Long memberId) { + this.commentId = commentId; + this.memberId = memberId; + } + + public static CommentLike of(final Long commentId, final Long memberId) { + return new CommentLike(commentId, memberId); + } +} diff --git a/src/main/java/ject/componote/domain/comment/domain/CommentRepository.java b/src/main/java/ject/componote/domain/comment/domain/CommentRepository.java deleted file mode 100644 index ff52d3b7..00000000 --- a/src/main/java/ject/componote/domain/comment/domain/CommentRepository.java +++ /dev/null @@ -1,6 +0,0 @@ -package ject.componote.domain.comment.domain; - -import org.springframework.data.jpa.repository.JpaRepository; - -public interface CommentRepository extends JpaRepository { -} diff --git a/src/main/java/ject/componote/domain/comment/dto/create/request/CommentCreateRequest.java b/src/main/java/ject/componote/domain/comment/dto/create/request/CommentCreateRequest.java index 191bd3c0..fb4c6873 100644 --- a/src/main/java/ject/componote/domain/comment/dto/create/request/CommentCreateRequest.java +++ b/src/main/java/ject/componote/domain/comment/dto/create/request/CommentCreateRequest.java @@ -5,7 +5,7 @@ import jakarta.validation.constraints.NotNull; public record CommentCreateRequest( - @Nullable String image, + @Nullable String imageObjectKey, @NotBlank String content, @NotNull Long componentId, @Nullable Long parentId diff --git a/src/main/java/ject/componote/domain/comment/dto/create/response/CommentCreateResponse.java b/src/main/java/ject/componote/domain/comment/dto/create/response/CommentCreateResponse.java index c568aadc..c9df5dc2 100644 --- a/src/main/java/ject/componote/domain/comment/dto/create/response/CommentCreateResponse.java +++ b/src/main/java/ject/componote/domain/comment/dto/create/response/CommentCreateResponse.java @@ -1,4 +1,9 @@ package ject.componote.domain.comment.dto.create.response; -public record CommentCreateResponse() { +import ject.componote.domain.comment.domain.Comment; + +public record CommentCreateResponse(Long id) { + public static CommentCreateResponse from(final Comment comment) { + return new CommentCreateResponse(comment.getId()); + } } diff --git a/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByComponentResponse.java b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByComponentResponse.java new file mode 100644 index 00000000..ab895b91 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByComponentResponse.java @@ -0,0 +1,42 @@ +package ject.componote.domain.comment.dto.find.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import ject.componote.domain.comment.dao.CommentFindByComponentDao; + +import java.time.LocalDateTime; + +public record CommentFindByComponentResponse( + CommentProfileResponse profile, + Long id, + @JsonInclude(JsonInclude.Include.NON_NULL) Long parentId, + @JsonInclude(JsonInclude.Include.NON_NULL) String imageUrl, + String content, + LocalDateTime createdAt, + Long likeCount, + Long replyCount, + boolean isLiked, + boolean isReply) { + public static CommentFindByComponentResponse from(final CommentFindByComponentDao dto) { + return new CommentFindByComponentResponse( + createProfileResponse(dto), + dto.commentId(), + dto.parentId(), + dto.commentImage().toUrl(), + dto.content().getValue(), + dto.createdAt(), + dto.likeCount().getValue(), + dto.replyCount().getValue(), + dto.isLiked(), + dto.isReply() + ); + } + + private static CommentProfileResponse createProfileResponse(final CommentFindByComponentDao dto) { + return new CommentProfileResponse( + dto.memberId(), + dto.nickname().getValue(), + dto.profileImage().toUrl(), + dto.job().name() + ); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByMemberResponse.java b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByMemberResponse.java new file mode 100644 index 00000000..6895e4e3 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByMemberResponse.java @@ -0,0 +1,23 @@ +package ject.componote.domain.comment.dto.find.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import ject.componote.domain.comment.dao.CommentFindByMemberDao; + +import java.time.LocalDateTime; + +public record CommentFindByMemberResponse(Long id, @JsonInclude(JsonInclude.Include.NON_NULL) Long parentId, + String componentTitle, + @JsonInclude(JsonInclude.Include.NON_NULL) String parentContent, + String content, LocalDateTime createdAt, boolean isReply) { + public static CommentFindByMemberResponse from(final CommentFindByMemberDao commentFindByMemberDao) { + return new CommentFindByMemberResponse( + commentFindByMemberDao.id(), + commentFindByMemberDao.isReply() ? commentFindByMemberDao.parentId() : null, + commentFindByMemberDao.componentTitle(), + commentFindByMemberDao.isReply() ? commentFindByMemberDao.parentContent().getValue() : null, + commentFindByMemberDao.content().getValue(), + commentFindByMemberDao.createdAt(), + commentFindByMemberDao.isReply() + ); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByParentResponse.java b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByParentResponse.java new file mode 100644 index 00000000..665680eb --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentFindByParentResponse.java @@ -0,0 +1,37 @@ +package ject.componote.domain.comment.dto.find.response; + +import com.fasterxml.jackson.annotation.JsonInclude; +import ject.componote.domain.comment.dao.CommentFindByParentDao; + +import java.time.LocalDateTime; + +public record CommentFindByParentResponse( + CommentProfileResponse profile, + Long id, + @JsonInclude(JsonInclude.Include.NON_NULL) String imageUrl, + String content, + LocalDateTime createdAt, + Long likeCount, + boolean isLiked +) { + public static CommentFindByParentResponse from(final CommentFindByParentDao dto) { + return new CommentFindByParentResponse( + createProfileResponse(dto), + dto.commentId(), + dto.commentImage().getImage().toUrl(), + dto.content().getValue(), + dto.createdAt(), + dto.likeCount().getValue(), + dto.isLiked() + ); + } + + private static CommentProfileResponse createProfileResponse(final CommentFindByParentDao dto) { + return new CommentProfileResponse( + dto.memberId(), + dto.nickname().getValue(), + dto.profileImage().getImage().toUrl(), + dto.job().name() + ); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/find/response/CommentProfileResponse.java b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentProfileResponse.java new file mode 100644 index 00000000..458597e5 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/find/response/CommentProfileResponse.java @@ -0,0 +1,14 @@ +package ject.componote.domain.comment.dto.find.response; + +import ject.componote.domain.comment.dao.CommentProfileDao; + +public record CommentProfileResponse(Long memberId, String nickname, String profileImageUrl, String job) { + public static CommentProfileResponse from(final CommentProfileDao profileDto) { + return new CommentProfileResponse( + profileDto.memberId(), + profileDto.nickname().getValue(), + profileDto.profileImage().getObjectKey(), + profileDto.job().name() + ); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/like/event/CommentLikeEvent.java b/src/main/java/ject/componote/domain/comment/dto/like/event/CommentLikeEvent.java new file mode 100644 index 00000000..12b96f3c --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/like/event/CommentLikeEvent.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.dto.like.event; + +import ject.componote.domain.auth.model.AuthPrincipal; + +public record CommentLikeEvent(Long commentId, Long memberId) { + public static CommentLikeEvent of(final AuthPrincipal authPrincipal, final Long commentId) { + return new CommentLikeEvent(commentId, authPrincipal.id()); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/like/event/CommentUnlikeEvent.java b/src/main/java/ject/componote/domain/comment/dto/like/event/CommentUnlikeEvent.java new file mode 100644 index 00000000..2237ef90 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/like/event/CommentUnlikeEvent.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.dto.like.event; + +import ject.componote.domain.auth.model.AuthPrincipal; + +public record CommentUnlikeEvent(Long commentId, Long memberId) { + public static CommentUnlikeEvent of(final AuthPrincipal authPrincipal, final Long commentId) { + return new CommentUnlikeEvent(commentId, authPrincipal.id()); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/reply/event/CommentReplyCountIncreaseEvent.java b/src/main/java/ject/componote/domain/comment/dto/reply/event/CommentReplyCountIncreaseEvent.java new file mode 100644 index 00000000..9baacd11 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/reply/event/CommentReplyCountIncreaseEvent.java @@ -0,0 +1,7 @@ +package ject.componote.domain.comment.dto.reply.event; + +public record CommentReplyCountIncreaseEvent(Long parentId) { + public static CommentReplyCountIncreaseEvent from(final Long parentId) { + return new CommentReplyCountIncreaseEvent(parentId); + } +} diff --git a/src/main/java/ject/componote/domain/comment/dto/update/request/CommentUpdateRequest.java b/src/main/java/ject/componote/domain/comment/dto/update/request/CommentUpdateRequest.java new file mode 100644 index 00000000..207c2bae --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/dto/update/request/CommentUpdateRequest.java @@ -0,0 +1,8 @@ +package ject.componote.domain.comment.dto.update.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; + +public record CommentUpdateRequest(@Nullable String imageObjectKey, + @NotBlank String content) { +} diff --git a/src/main/java/ject/componote/domain/comment/error/AlreadyLikedException.java b/src/main/java/ject/componote/domain/comment/error/AlreadyLikedException.java new file mode 100644 index 00000000..e4fc4dc7 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/AlreadyLikedException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class AlreadyLikedException extends CommentException { + public AlreadyLikedException(final Long commentId, final Long memberId) { + super("이미 좋아요를 누른 댓글입니다. 댓글 ID " + commentId + ", 회원 ID: " + memberId, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/BlankCommentException.java b/src/main/java/ject/componote/domain/comment/error/BlankCommentException.java new file mode 100644 index 00000000..aaceadc2 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/BlankCommentException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class BlankCommentException extends CommentException { + public BlankCommentException() { + super("댓글 내용이 없습니다.", HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/CommentException.java b/src/main/java/ject/componote/domain/comment/error/CommentException.java index 431e1d8c..1b74df80 100644 --- a/src/main/java/ject/componote/domain/comment/error/CommentException.java +++ b/src/main/java/ject/componote/domain/comment/error/CommentException.java @@ -1,4 +1,10 @@ package ject.componote.domain.comment.error; -public class CommentException { +import ject.componote.global.error.ComponoteException; +import org.springframework.http.HttpStatus; + +public class CommentException extends ComponoteException { + public CommentException(final String message, final HttpStatus status) { + super(message, status); + } } diff --git a/src/main/java/ject/componote/domain/comment/error/ExceedCommentLengthException.java b/src/main/java/ject/componote/domain/comment/error/ExceedCommentLengthException.java new file mode 100644 index 00000000..e4762707 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/ExceedCommentLengthException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class ExceedCommentLengthException extends CommentException{ + public ExceedCommentLengthException(final int length) { + super("댓글 길이가 너무 깁니다. 현재 길이: " + length, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/InvalidCommentContentException.java b/src/main/java/ject/componote/domain/comment/error/InvalidCommentContentException.java new file mode 100644 index 00000000..49050d50 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/InvalidCommentContentException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class InvalidCommentContentException extends CommentException { + public InvalidCommentContentException() { + super("댓글 내용이 잘못되었습니다.", HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/InvalidCommentCreateStrategyException.java b/src/main/java/ject/componote/domain/comment/error/InvalidCommentCreateStrategyException.java new file mode 100644 index 00000000..9ec22e01 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/InvalidCommentCreateStrategyException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class InvalidCommentCreateStrategyException extends CommentException { + public InvalidCommentCreateStrategyException() { + super("댓글 생성에 실패하였습니다. 요청 Body 확인해주세요.", HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/InvalidCommentImageExtensionException.java b/src/main/java/ject/componote/domain/comment/error/InvalidCommentImageExtensionException.java new file mode 100644 index 00000000..9953416e --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/InvalidCommentImageExtensionException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class InvalidCommentImageExtensionException extends CommentException{ + public InvalidCommentImageExtensionException(final String extension) { + super("확장자가 올바르지 않습니다. 입력된 확장자: " + extension, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/NoLikedException.java b/src/main/java/ject/componote/domain/comment/error/NoLikedException.java new file mode 100644 index 00000000..d281b1db --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/NoLikedException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class NoLikedException extends CommentException { + public NoLikedException(final Long commentId, final Long memberId) { + super("해당 댓글에 좋아요를 누르지 않았습니다. 댓글 ID:" + commentId + ", 회원 ID: " + memberId, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/NotFoundCommentException.java b/src/main/java/ject/componote/domain/comment/error/NotFoundCommentException.java new file mode 100644 index 00000000..49e8e68e --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/NotFoundCommentException.java @@ -0,0 +1,13 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class NotFoundCommentException extends CommentException { + public NotFoundCommentException(final Long commentId) { + super("댓글을 찾을 수 없습니다. 댓글 ID: " + commentId, HttpStatus.NOT_FOUND); + } + + public NotFoundCommentException(final Long commentId, final Long memberId) { + super("댓글을 찾을 수 없습니다. 댓글 ID: " + commentId + ", 회원 ID: " + memberId, HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/NotFoundParentCommentException.java b/src/main/java/ject/componote/domain/comment/error/NotFoundParentCommentException.java new file mode 100644 index 00000000..dcace58d --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/NotFoundParentCommentException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class NotFoundParentCommentException extends CommentException { + public NotFoundParentCommentException(final Long parentId) { + super("일치하는 부모 댓글을 찾을 수 없습니다. 댓글 ID: " + parentId, HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/ject/componote/domain/comment/error/OffensiveCommentException.java b/src/main/java/ject/componote/domain/comment/error/OffensiveCommentException.java new file mode 100644 index 00000000..973f72e6 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/error/OffensiveCommentException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.comment.error; + +import org.springframework.http.HttpStatus; + +public class OffensiveCommentException extends CommentException { + public OffensiveCommentException() { + super("적절하지 않은 댓글 내용입니다.", HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/comment/model/CommentContent.java b/src/main/java/ject/componote/domain/comment/model/CommentContent.java index d68e6806..b3a0e558 100644 --- a/src/main/java/ject/componote/domain/comment/model/CommentContent.java +++ b/src/main/java/ject/componote/domain/comment/model/CommentContent.java @@ -1,5 +1,9 @@ package ject.componote.domain.comment.model; +import ject.componote.domain.auth.domain.BadWordFilteringSingleton; +import ject.componote.domain.comment.error.BlankCommentException; +import ject.componote.domain.comment.error.ExceedCommentLengthException; +import ject.componote.domain.comment.error.OffensiveCommentException; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; @@ -8,6 +12,8 @@ @EqualsAndHashCode @ToString public class CommentContent { + private static final int MAX_LENGTH = 1_000; + private final String value; private CommentContent(final String value) { @@ -20,9 +26,16 @@ public static CommentContent from(final String value) { } private void validateContent(final String value) { - // 추가 제약조건 고려 (e.g. 글자수, 비속어 등) if (value == null || value.isBlank()) { - throw new IllegalArgumentException("Comment content cannot be null or blank"); + throw new BlankCommentException(); + } + + if (value.length() > MAX_LENGTH) { + throw new ExceedCommentLengthException(value.length()); + } + + if (BadWordFilteringSingleton.containsBadWord(value)) { + throw new OffensiveCommentException(); } } } diff --git a/src/main/java/ject/componote/domain/comment/model/CommentImage.java b/src/main/java/ject/componote/domain/comment/model/CommentImage.java new file mode 100644 index 00000000..5fb226bf --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/model/CommentImage.java @@ -0,0 +1,34 @@ +package ject.componote.domain.comment.model; + +import ject.componote.domain.comment.error.InvalidCommentImageExtensionException; +import ject.componote.domain.common.model.AbstractImage; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@ToString +public class CommentImage extends AbstractImage { + private static final List ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png", "gif"); + private static final CommentImage EMPTY_INSTANCE = new CommentImage(null); + + public CommentImage(final String objectKey) { + super(objectKey); + } + + public static CommentImage from(final String objectKey) { + if (objectKey == null || objectKey.isEmpty()) { + return EMPTY_INSTANCE; + } + + final String extension = StringUtils.getFilenameExtension(objectKey); + if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) { + throw new InvalidCommentImageExtensionException(extension); + } + + return new CommentImage(objectKey); + } +} diff --git a/src/main/java/ject/componote/domain/comment/model/converter/CommentImageConverter.java b/src/main/java/ject/componote/domain/comment/model/converter/CommentImageConverter.java new file mode 100644 index 00000000..adabc6db --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/model/converter/CommentImageConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.comment.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.comment.model.CommentImage; + +@Converter +public class CommentImageConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final CommentImage attribute) { + return attribute.getObjectKey(); + } + + @Override + public CommentImage convertToEntityAttribute(final String dbData) { + return CommentImage.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/comment/validation/CommenterValidation.java b/src/main/java/ject/componote/domain/comment/validation/CommenterValidation.java new file mode 100644 index 00000000..54a6a769 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/validation/CommenterValidation.java @@ -0,0 +1,11 @@ +package ject.componote.domain.comment.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface CommenterValidation { +} diff --git a/src/main/java/ject/componote/domain/comment/validation/CommenterValidationAspect.java b/src/main/java/ject/componote/domain/comment/validation/CommenterValidationAspect.java new file mode 100644 index 00000000..e3d87e96 --- /dev/null +++ b/src/main/java/ject/componote/domain/comment/validation/CommenterValidationAspect.java @@ -0,0 +1,28 @@ +package ject.componote.domain.comment.validation; + +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.error.NotFoundCommentException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.annotation.Before; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Aspect +@Component +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class CommenterValidationAspect { + private final CommentRepository commentRepository; + + @Before(value = "@annotation(ject.componote.domain.comment.validation.CommenterValidation) && args(authPrincipal, commentId, ..)", argNames = "authPrincipal,commentId") + public void validate(final AuthPrincipal authPrincipal, final Long commentId) { + final Long memberId = authPrincipal.id(); + if (!commentRepository.existsByIdAndMemberId(commentId, memberId)) { + throw new NotFoundCommentException(commentId, memberId); + } + } +} diff --git a/src/main/java/ject/componote/domain/common/dto/response/PageResponse.java b/src/main/java/ject/componote/domain/common/dto/response/PageResponse.java index 13edacfe..a293d91c 100644 --- a/src/main/java/ject/componote/domain/common/dto/response/PageResponse.java +++ b/src/main/java/ject/componote/domain/common/dto/response/PageResponse.java @@ -2,6 +2,7 @@ import lombok.AccessLevel; import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.ToString; import org.springframework.data.domain.Page; @@ -9,15 +10,18 @@ import java.util.List; @AllArgsConstructor(access = AccessLevel.PRIVATE) +@EqualsAndHashCode @Getter @ToString public class PageResponse { - private final int totalCount; + private final boolean hasNext; + private final long totalElements; + private final int totalPages; private final List content; private final int pageNumber; private final int pageSize; public static PageResponse from(final Page page) { - return new PageResponse<>(page.getTotalPages(), page.getContent(), page.getNumber(), page.getSize()); + return new PageResponse<>(page.hasNext(), page.getTotalElements(), page.getTotalPages(), page.getContent(), page.getNumber(), page.getSize()); } } diff --git a/src/main/java/ject/componote/domain/common/model/AbstractImage.java b/src/main/java/ject/componote/domain/common/model/AbstractImage.java new file mode 100644 index 00000000..0b678602 --- /dev/null +++ b/src/main/java/ject/componote/domain/common/model/AbstractImage.java @@ -0,0 +1,30 @@ +package ject.componote.domain.common.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@EqualsAndHashCode +@Getter +@ToString +public abstract class AbstractImage { + private static final String IMAGE_URL_PREFIX = "https://componote.s3.ap-northeast-2.amazonaws.com/data/"; + + private final String objectKey; + + protected AbstractImage(final String objectKey) { + this.objectKey = objectKey; + } + + public boolean isEmpty() { + return objectKey == null || objectKey.isEmpty(); + } + + public String toUrl() { + if (isEmpty()) { + return null; + } + + return IMAGE_URL_PREFIX + objectKey; + } +} diff --git a/src/main/java/ject/componote/domain/common/model/BaseImage.java b/src/main/java/ject/componote/domain/common/model/BaseImage.java index 543ae463..2714fd28 100644 --- a/src/main/java/ject/componote/domain/common/model/BaseImage.java +++ b/src/main/java/ject/componote/domain/common/model/BaseImage.java @@ -8,7 +8,7 @@ @EqualsAndHashCode @ToString public class BaseImage { - private static final String IMAGE_URL_PREFIX = "https://componote.s3.ap-northeast-2.amazonaws.com/permanent"; + private static final String IMAGE_URL_PREFIX = "https://componote.s3.ap-northeast-2.amazonaws.com/permanent/"; private static final BaseImage EMPTY_INSTANCE = new BaseImage(null); private final String objectKey; diff --git a/src/main/java/ject/componote/domain/common/model/Count.java b/src/main/java/ject/componote/domain/common/model/Count.java index 227bf71a..41285222 100644 --- a/src/main/java/ject/componote/domain/common/model/Count.java +++ b/src/main/java/ject/componote/domain/common/model/Count.java @@ -7,7 +7,7 @@ @Getter @EqualsAndHashCode @ToString -public class Count { +public class Count implements Comparable { private Long value; private Count(final Long value) { @@ -36,4 +36,9 @@ private void validateCount(final Long value) { throw new IllegalArgumentException("Value must be greater than zero"); } } + + @Override + public int compareTo(final Count other) { + return Long.compare(this.value, other.value); + } } diff --git a/src/main/java/ject/componote/domain/component/api/ComponentController.java b/src/main/java/ject/componote/domain/component/api/ComponentController.java new file mode 100644 index 00000000..e7093ed4 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/api/ComponentController.java @@ -0,0 +1,45 @@ +package ject.componote.domain.component.api; + +import jakarta.validation.Valid; +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.auth.model.Authenticated; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.component.application.ComponentService; +import ject.componote.domain.component.dto.find.request.ComponentSearchRequest; +import ject.componote.domain.component.dto.find.response.ComponentSummaryResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/components") +@RequiredArgsConstructor +@RestController +public class ComponentController { + private final ComponentService componentService; + + @GetMapping("/search") + public ResponseEntity> search( + @Authenticated final AuthPrincipal authPrincipal, + @ModelAttribute @Valid final ComponentSearchRequest request, + @PageableDefault final Pageable pageable + ) { + return ResponseEntity.ok( + componentService.search(authPrincipal, request, pageable) + ); + } + + @GetMapping("/{componentId}") + public ResponseEntity getComponentDetail( + @Authenticated final AuthPrincipal authPrincipal, + @PathVariable("componentId") final Long componentId) { + return ResponseEntity.ok( + componentService.getComponentDetail(authPrincipal, componentId) + ); + } +} diff --git a/src/main/java/ject/componote/domain/component/application/ComponentSearchStrategy.java b/src/main/java/ject/componote/domain/component/application/ComponentSearchStrategy.java new file mode 100644 index 00000000..00fada48 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/application/ComponentSearchStrategy.java @@ -0,0 +1,67 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.dao.ComponentSummaryDao; +import ject.componote.domain.component.dto.find.request.ComponentSearchRequest; +import ject.componote.domain.component.error.InvalidCommentSearchStrategyException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Arrays; +import java.util.function.BiPredicate; + +@RequiredArgsConstructor +public enum ComponentSearchStrategy { + WITH_BOOKMARK_AND_FILTER( + (authPrincipal, request) -> isLoggedIn(authPrincipal) && hasFilter(request), + (authPrincipal, componentRepository, request, pageable) -> + componentRepository.searchWithBookmarkAndTypes(authPrincipal.id(), request.keyword(), request.types(), pageable) + ), + + WITH_BOOKMARK( + (authPrincipal, request) -> isLoggedIn(authPrincipal) && !hasFilter(request), + (authPrincipal, componentRepository, request, pageable) -> + componentRepository.searchWithBookmark(authPrincipal.id(), request.keyword(), pageable) + ), + + WITHOUT_BOOKMARK_AND_FILTER( + (authPrincipal, request) -> !isLoggedIn(authPrincipal) && hasFilter(request), + (authPrincipal, componentRepository, request, pageable) -> + componentRepository.searchByKeywordWithTypes(request.keyword(), request.types(), pageable) + ), + + WITHOUT_BOOKMARK( + (authPrincipal, request) -> !isLoggedIn(authPrincipal) && !hasFilter(request), + (authPrincipal, componentRepository, request, pageable) -> + componentRepository.searchByKeyword(request.keyword(), pageable) + ); + + private final BiPredicate condition; + private final ComponentSearchFunction searchFunction; + + public static Page searchBy(final AuthPrincipal authPrincipal, + final ComponentRepository componentRepository, + final ComponentSearchRequest request, + final Pageable pageable) { + return Arrays.stream(values()) + .filter(strategy -> strategy.condition.test(authPrincipal, request)) + .findFirst() + .orElseThrow(InvalidCommentSearchStrategyException::new) + .searchFunction.search(authPrincipal, componentRepository, request, pageable); + } + + private static boolean isLoggedIn(final AuthPrincipal authPrincipal) { + return authPrincipal != null; + } + + private static boolean hasFilter(final ComponentSearchRequest request) { + return request.types() != null && !request.types().isEmpty(); + } + + @FunctionalInterface + private interface ComponentSearchFunction { + Page search(final AuthPrincipal authPrincipal, final ComponentRepository componentRepository, final ComponentSearchRequest request, final Pageable pageable); + } +} diff --git a/src/main/java/ject/componote/domain/component/application/ComponentService.java b/src/main/java/ject/componote/domain/component/application/ComponentService.java new file mode 100644 index 00000000..2276772e --- /dev/null +++ b/src/main/java/ject/componote/domain/component/application/ComponentService.java @@ -0,0 +1,57 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.bookmark.dao.BookmarkRepository; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.dto.find.event.ComponentViewCountIncreaseEvent; +import ject.componote.domain.component.dto.find.request.ComponentSearchRequest; +import ject.componote.domain.component.dto.find.response.ComponentDetailResponse; +import ject.componote.domain.component.dto.find.response.ComponentSummaryResponse; +import ject.componote.domain.component.error.NotFoundComponentException; +import ject.componote.domain.component.util.ComponentMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ComponentService { + private final ApplicationEventPublisher eventPublisher; + private final BookmarkRepository bookmarkRepository; + private final ComponentMapper componentMapper; + private final ComponentRepository componentRepository; + + public ComponentDetailResponse getComponentDetail(final AuthPrincipal authPrincipal, final Long componentId) { + final Component component = findComponentById(componentId); + eventPublisher.publishEvent(ComponentViewCountIncreaseEvent.from(component)); + return componentMapper.mapFrom(component, isBookmarked(authPrincipal, componentId)); + } + + public PageResponse search(final AuthPrincipal authPrincipal, + final ComponentSearchRequest request, + final Pageable pageable) { + final Page page = ComponentSearchStrategy.searchBy(authPrincipal, componentRepository, request, pageable) + .map(ComponentSummaryResponse::from); + return PageResponse.from(page); + } + + private Component findComponentById(final Long componentId) { + return componentRepository.findById(componentId) + .orElseThrow(() -> new NotFoundComponentException(componentId)); + } + + private boolean isBookmarked(final AuthPrincipal authPrincipal, final Long componentId) { + if (authPrincipal == null) { + return false; + } + + final Long memberId = authPrincipal.id(); + return bookmarkRepository.existsByComponentIdAndMemberId(componentId, memberId); + } +} diff --git a/src/main/java/ject/componote/domain/component/application/ComponentViewCountEventHandler.java b/src/main/java/ject/componote/domain/component/application/ComponentViewCountEventHandler.java new file mode 100644 index 00000000..52169b03 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/application/ComponentViewCountEventHandler.java @@ -0,0 +1,30 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.dto.find.event.ComponentViewCountIncreaseEvent; +import ject.componote.domain.component.error.NotFoundComponentException; +import lombok.RequiredArgsConstructor; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.annotation.Transactional; + +@org.springframework.stereotype.Component +@RequiredArgsConstructor +public class ComponentViewCountEventHandler { + private final ComponentRepository componentRepository; + + @Async + @EventListener + @Transactional + public void handleViewCountIncrease(final ComponentViewCountIncreaseEvent event) { + final Long componentId = event.componentId(); + final Component component = findComponentById(componentId); + component.increaseViewCount(); + } + + private Component findComponentById(final Long componentId) { + return componentRepository.findById(componentId) + .orElseThrow(() -> new NotFoundComponentException(componentId)); + } +} diff --git a/src/main/java/ject/componote/domain/component/dao/ComponentQueryDsl.java b/src/main/java/ject/componote/domain/component/dao/ComponentQueryDsl.java new file mode 100644 index 00000000..6ea0ae4b --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dao/ComponentQueryDsl.java @@ -0,0 +1,14 @@ +package ject.componote.domain.component.dao; + +import ject.componote.domain.component.domain.ComponentType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +public interface ComponentQueryDsl { + Page searchByKeywordWithTypes(final String keyword, final List types, final Pageable pageable); + Page searchByKeyword(final String keyword, final Pageable pageable); + Page searchWithBookmark(final Long memberId, final String keyword, final Pageable pageable); + Page searchWithBookmarkAndTypes(final Long memberId, final String keyword, final List types, final Pageable pageable); +} diff --git a/src/main/java/ject/componote/domain/component/dao/ComponentQueryDslImpl.java b/src/main/java/ject/componote/domain/component/dao/ComponentQueryDslImpl.java new file mode 100644 index 00000000..4214b5c9 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dao/ComponentQueryDslImpl.java @@ -0,0 +1,91 @@ +package ject.componote.domain.component.dao; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import ject.componote.domain.component.domain.ComponentType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.List; + +import static ject.componote.domain.bookmark.domain.QBookmark.bookmark; +import static ject.componote.domain.component.domain.QComponent.component; +import static ject.componote.domain.component.domain.QMixedName.mixedName; +import static ject.componote.global.util.RepositoryUtils.eqExpression; +import static ject.componote.global.util.RepositoryUtils.toPage; + +@RequiredArgsConstructor +public class ComponentQueryDslImpl implements ComponentQueryDsl { + private final QComponentDaoFactory componentDaoFactory; + private final JPAQueryFactory queryFactory; + + @Override + public Page searchByKeyword(final String keyword, final Pageable pageable) { + return search(null, keyword, null, pageable, false); + } + + @Override + public Page searchByKeywordWithTypes(final String keyword, final List types, final Pageable pageable) { + return search(null, keyword, types, pageable, false); + } + + @Override + public Page searchWithBookmark(final Long memberId, final String keyword, final Pageable pageable) { + return search(memberId, keyword, null, pageable, true); + } + + @Override + public Page searchWithBookmarkAndTypes(final Long memberId, final String keyword, final List types, final Pageable pageable) { + return search(memberId, keyword, types, pageable, true); + } + + public Page search(final Long memberId, + final String keyword, + final List types, + final Pageable pageable, + final boolean withBookmark) { + final JPAQuery countQuery = createCountQuery(keyword, types); + final JPAQuery baseQuery = createBaseQuery(memberId, keyword, types, withBookmark); + return toPage(baseQuery, countQuery, component, pageable); + } + + private JPAQuery createCountQuery(final String keyword, final List types) { + return queryFactory.select(component.countDistinct()) + .from(component) + .leftJoin(component.mixedNames, mixedName) + .where(createSearchCondition(keyword, types)); + } + + private JPAQuery createBaseQuery(final Long memberId, + final String keyword, + final List types, + final boolean withBookmark) { + final JPAQuery query = queryFactory.selectDistinct(componentDaoFactory.createForSummary(withBookmark)) + .from(component) + .leftJoin(component.mixedNames, mixedName); + + if (withBookmark && memberId != null) { + query.leftJoin(bookmark) + .on(eqExpression(bookmark.componentId, component.id) + .and(eqExpression(bookmark.memberId, memberId))); + } + + return query.where(createSearchCondition(keyword, types)); + } + + private BooleanExpression createSearchCondition(final String keyword, final List types) { + final BooleanExpression keywordCondition = createKeywordCondition(keyword); + if (types != null && !types.isEmpty()) { + return keywordCondition.and(component.type.in(types)); + } + + return keywordCondition; + } + + private static BooleanExpression createKeywordCondition(final String keyword) { + return mixedName.name.contains(keyword) + .or(component.summary.title.contains(keyword)); + } +} diff --git a/src/main/java/ject/componote/domain/component/dao/ComponentRepository.java b/src/main/java/ject/componote/domain/component/dao/ComponentRepository.java new file mode 100644 index 00000000..a405be1e --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dao/ComponentRepository.java @@ -0,0 +1,16 @@ +package ject.componote.domain.component.dao; + +import ject.componote.domain.component.domain.Component; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ComponentRepository extends JpaRepository, ComponentQueryDsl { + @Query( + nativeQuery = true, + value = "UPDATE component c SET c.view_count = c.view_count + 1 WHERE c.id =:id" + ) + @Modifying(clearAutomatically = true) + void increaseViewCount(@Param("id") final Long id); // 변경 감지 대신 사용할 메서드 +} diff --git a/src/main/java/ject/componote/domain/component/dao/ComponentSummaryDao.java b/src/main/java/ject/componote/domain/component/dao/ComponentSummaryDao.java new file mode 100644 index 00000000..420bf7b7 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dao/ComponentSummaryDao.java @@ -0,0 +1,17 @@ +package ject.componote.domain.component.dao; + +import ject.componote.domain.common.model.Count; +import ject.componote.domain.component.domain.ComponentType; +import ject.componote.domain.component.domain.summary.ComponentSummary; + +public record ComponentSummaryDao( + Long id, + ComponentSummary summary, + ComponentType type, + Count bookmarkCount, + Count commentCount, + Count designReferenceCount, + Count viewCount, + Boolean isBookmarked +) { +} diff --git a/src/main/java/ject/componote/domain/component/dao/QComponentDaoFactory.java b/src/main/java/ject/componote/domain/component/dao/QComponentDaoFactory.java new file mode 100644 index 00000000..b55d6167 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dao/QComponentDaoFactory.java @@ -0,0 +1,40 @@ +package ject.componote.domain.component.dao; + +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.Expressions; +import org.springframework.stereotype.Component; + +import static ject.componote.domain.bookmark.domain.QBookmark.bookmark; +import static ject.componote.domain.component.domain.QComponent.component; + +@Component +public class QComponentDaoFactory { + public ConstructorExpression createForSummary(final boolean withBookmark) { + if (withBookmark) { + return Projections.constructor( + ComponentSummaryDao.class, + component.id, + component.summary, + component.type, + component.bookmarkCount, + component.commentCount, + component.designReferenceCount, + component.viewCount, + bookmark.isNotNull() + ); + } + + return Projections.constructor( + ComponentSummaryDao.class, + component.id, + component.summary, + component.type, + component.bookmarkCount, + component.commentCount, + component.designReferenceCount, + component.viewCount, + Expressions.asBoolean(false) + ); + } +} diff --git a/src/main/java/ject/componote/domain/component/domain/Component.java b/src/main/java/ject/componote/domain/component/domain/Component.java index 620c8b86..5f553de6 100644 --- a/src/main/java/ject/componote/domain/component/domain/Component.java +++ b/src/main/java/ject/componote/domain/component/domain/Component.java @@ -16,15 +16,17 @@ import ject.componote.domain.common.domain.BaseEntity; import ject.componote.domain.common.model.Count; import ject.componote.domain.common.model.converter.CountConverter; -import ject.componote.domain.component.domain.detail.block.ContentBlock; +import ject.componote.domain.component.domain.block.ContentBlock; import ject.componote.domain.component.domain.summary.ComponentSummary; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.DynamicUpdate; import java.util.ArrayList; import java.util.List; +@DynamicUpdate @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -44,7 +46,7 @@ public class Component extends BaseEntity { @Embedded private ComponentSummary summary; - @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) + @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = "component_id") private List contentBlocks = new ArrayList<>(); @@ -56,29 +58,37 @@ public class Component extends BaseEntity { @Convert(converter = CountConverter.class) private Count commentCount; - private Component(final ComponentType type, final List mixedNames, final ComponentSummary summary) { + @Column(name = "design_reference_count", nullable = false) + @Convert(converter = CountConverter.class) + private Count designReferenceCount; + + @Column(name = "view_count", nullable = false) + @Convert(converter = CountConverter.class) + private Count viewCount; + + private Component(final ComponentType type, final List mixedNames, final ComponentSummary summary, final List contentBlocks) { this.type = type; - this.mixedNames.addAll(mixedNames); + this.mixedNames.addAll(parseMixedNames(mixedNames)); this.summary = summary; + this.contentBlocks.addAll(contentBlocks); this.bookmarkCount = Count.create(); this.commentCount = Count.create(); + this.designReferenceCount = Count.create(); + this.viewCount = Count.create(); } - public static Component of(final ComponentType type, final List mixedNames, final ComponentSummary summary) { - return new Component(type, mixedNames, summary); - } - - public void addBlock(final ContentBlock contentBlock) { - this.contentBlocks.add(contentBlock); + public static Component of(final String title, final String description, final String thumbnailObjectKey, final ComponentType type, final List mixedNames, final List contentBlocks) { + return new Component(type, mixedNames, ComponentSummary.of(title, description, thumbnailObjectKey), contentBlocks); } - // 중복 검사 필요... Set으로 둬야되나? 너무 비효율적일 것 같음... - public void addMixedName(final String mixedName) { - mixedNames.add(MixedName.from(mixedName)); + public void increaseViewCount() { + this.viewCount.increase(); } - public void removeMixedName(final String mixedName) { - mixedNames.remove(MixedName.from(mixedName)); + private List parseMixedNames(final List mixedNames) { + return mixedNames.stream() + .map(MixedName::from) + .toList(); } @Override @@ -89,6 +99,8 @@ public String toString() { ", summary=" + summary + ", commentCount=" + commentCount + ", bookmarkCount=" + bookmarkCount + + ", designReferenceCount=" + designReferenceCount + + ", viewCount=" + viewCount + '}'; } } diff --git a/src/main/java/ject/componote/domain/component/domain/ComponentDesign.java b/src/main/java/ject/componote/domain/component/domain/ComponentDesign.java new file mode 100644 index 00000000..25e04df2 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/domain/ComponentDesign.java @@ -0,0 +1,36 @@ +package ject.componote.domain.component.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.Id; +import ject.componote.domain.design.domain.Design; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@ToString +public class ComponentDesign { + @Id + @GeneratedValue + private Long id; + + @Column(name = "component_id", nullable = false) + private Long componentId; + + @Column(name = "design_id", nullable = false) + private Long designId; + + private ComponentDesign(final Long componentId, final Long designId) { + this.componentId = componentId; + this.designId = designId; + } + + public static ComponentDesign from(final Component component, final Design design) { + return new ComponentDesign(component.getId(), design.getId()); + } +} diff --git a/src/main/java/ject/componote/domain/component/domain/block/BlockType.java b/src/main/java/ject/componote/domain/component/domain/block/BlockType.java new file mode 100644 index 00000000..6b1b2a73 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/domain/block/BlockType.java @@ -0,0 +1,5 @@ +package ject.componote.domain.component.domain.block; + +public enum BlockType { + INTRODUCTION, DESCRIPTION, USE_CASE, REFERENCE; +} diff --git a/src/main/java/ject/componote/domain/component/domain/detail/block/ContentBlock.java b/src/main/java/ject/componote/domain/component/domain/block/ContentBlock.java similarity index 80% rename from src/main/java/ject/componote/domain/component/domain/detail/block/ContentBlock.java rename to src/main/java/ject/componote/domain/component/domain/block/ContentBlock.java index 68b387c8..6ba31632 100644 --- a/src/main/java/ject/componote/domain/component/domain/detail/block/ContentBlock.java +++ b/src/main/java/ject/componote/domain/component/domain/block/ContentBlock.java @@ -1,4 +1,4 @@ -package ject.componote.domain.component.domain.detail.block; +package ject.componote.domain.component.domain.block; import jakarta.persistence.Column; import jakarta.persistence.Entity; @@ -9,7 +9,6 @@ import jakarta.persistence.Id; import jakarta.persistence.Inheritance; import jakarta.persistence.InheritanceType; -import ject.componote.domain.component.domain.detail.DetailType; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -27,13 +26,15 @@ public abstract class ContentBlock { @Column(name = "type", nullable = false) @Enumerated(EnumType.STRING) - private DetailType type; + private BlockType type; @Column(name = "orders", nullable = false) private Integer order; - public ContentBlock(final DetailType type, final Integer order) { + public ContentBlock(final BlockType type, final Integer order) { this.type = type; this.order = order; } + + public abstract String getValue(); } diff --git a/src/main/java/ject/componote/domain/component/domain/block/detail/ImageBlock.java b/src/main/java/ject/componote/domain/component/domain/block/detail/ImageBlock.java index ae85b680..b393263e 100644 --- a/src/main/java/ject/componote/domain/component/domain/block/detail/ImageBlock.java +++ b/src/main/java/ject/componote/domain/component/domain/block/detail/ImageBlock.java @@ -3,10 +3,10 @@ import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; -import ject.componote.domain.common.model.BaseImage; -import ject.componote.domain.common.model.converter.BaseImageConverter; import ject.componote.domain.component.domain.block.BlockType; import ject.componote.domain.component.domain.block.ContentBlock; +import ject.componote.domain.component.model.ComponentImage; +import ject.componote.domain.component.model.converter.ComponentImageConverter; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -17,16 +17,21 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString public class ImageBlock extends ContentBlock { - @Convert(converter = BaseImageConverter.class) + @Convert(converter = ComponentImageConverter.class) @Column(name = "image", nullable = false) - private BaseImage image; + private ComponentImage image; - private ImageBlock(final BlockType type, final BaseImage image, final Integer order) { + private ImageBlock(final BlockType type, final ComponentImage image, final Integer order) { super(type, order); this.image = image; } - public static ImageBlock of(final BlockType type, final BaseImage image, final Integer order) { + public static ImageBlock of(final BlockType type, final ComponentImage image, final Integer order) { return new ImageBlock(type, image, order); } + + @Override + public String getValue() { + return image.toUrl(); + } } \ No newline at end of file diff --git a/src/main/java/ject/componote/domain/component/domain/block/detail/TextBlock.java b/src/main/java/ject/componote/domain/component/domain/block/detail/TextBlock.java index 050540c9..8f8baf9c 100644 --- a/src/main/java/ject/componote/domain/component/domain/block/detail/TextBlock.java +++ b/src/main/java/ject/componote/domain/component/domain/block/detail/TextBlock.java @@ -3,8 +3,8 @@ import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Entity; -import ject.componote.domain.component.domain.block.BlockType; -import ject.componote.domain.component.domain.block.ContentBlock; +import ject.componote.domain.component.domain.detail.DetailType; +import ject.componote.domain.component.domain.detail.block.ContentBlock; import ject.componote.domain.component.model.ComponentContent; import ject.componote.domain.component.model.converter.ComponentContentConverter; import lombok.AccessLevel; @@ -21,12 +21,17 @@ public class TextBlock extends ContentBlock { @Column(name = "content", nullable = false) private ComponentContent content; - private TextBlock(final BlockType type, final ComponentContent content, final Integer order) { + private TextBlock(final DetailType type, final ComponentContent content, final Integer order) { super(type, order); this.content = content; } - public static TextBlock of(final BlockType type, final ComponentContent content, final Integer order) { + public static TextBlock of(final DetailType type, final ComponentContent content, final Integer order) { return new TextBlock(type, content, order); } + + @Override + public String getValue() { + return content.getValue(); + } } diff --git a/src/main/java/ject/componote/domain/component/domain/detail/DetailType.java b/src/main/java/ject/componote/domain/component/domain/detail/DetailType.java deleted file mode 100644 index 28554810..00000000 --- a/src/main/java/ject/componote/domain/component/domain/detail/DetailType.java +++ /dev/null @@ -1,5 +0,0 @@ -package ject.componote.domain.component.domain.detail; - -public enum DetailType { - INTRODUCTION, DESCRIPTION, USE_CASE, REFERENCE; -} diff --git a/src/main/java/ject/componote/domain/component/domain/summary/ComponentSummary.java b/src/main/java/ject/componote/domain/component/domain/summary/ComponentSummary.java index 57b01949..c056c605 100644 --- a/src/main/java/ject/componote/domain/component/domain/summary/ComponentSummary.java +++ b/src/main/java/ject/componote/domain/component/domain/summary/ComponentSummary.java @@ -3,14 +3,16 @@ import jakarta.persistence.Column; import jakarta.persistence.Convert; import jakarta.persistence.Embeddable; -import ject.componote.domain.common.model.Image; -import ject.componote.domain.common.model.converter.ImageConverter; +import ject.componote.domain.component.model.ComponentThumbnail; +import ject.componote.domain.component.model.converter.ComponentThumbnailConverter; import lombok.AccessLevel; +import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.NoArgsConstructor; import lombok.ToString; @Embeddable +@EqualsAndHashCode @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @ToString @@ -18,18 +20,18 @@ public class ComponentSummary { @Column(name = "title", nullable = false) private String title; - @Column(name = "summary", nullable = false) - private String summary; + @Column(name = "description", nullable = false) + private String description; - @Convert(converter = ImageConverter.class) + @Convert(converter = ComponentThumbnailConverter.class) @Column(name = "thumbnail", nullable = false) - private Image thumbnail; + private ComponentThumbnail thumbnail; - private ComponentSummary(final String title, final String summary, final Image thumbnail) { + private ComponentSummary(final String title, final String description, final ComponentThumbnail thumbnail) { validateTitle(title); - validateSummary(summary); + validateDescription(description); this.title = title; - this.summary = summary; + this.description = description; this.thumbnail = thumbnail; } @@ -37,11 +39,11 @@ private void validateTitle(final String title) { } - private void validateSummary(final String summary) { + private void validateDescription(final String description) { } - public static ComponentSummary of(final String title, final String summary, final Image thumbnail) { - return new ComponentSummary(title, summary, thumbnail); + public static ComponentSummary of(final String title, final String description, final String thumbnailObjectKey) { + return new ComponentSummary(title, description, ComponentThumbnail.from(thumbnailObjectKey)); } } diff --git a/src/main/java/ject/componote/domain/component/dto/find/event/ComponentViewCountIncreaseEvent.java b/src/main/java/ject/componote/domain/component/dto/find/event/ComponentViewCountIncreaseEvent.java new file mode 100644 index 00000000..15315f7b --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dto/find/event/ComponentViewCountIncreaseEvent.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.dto.find.event; + +import ject.componote.domain.component.domain.Component; + +public record ComponentViewCountIncreaseEvent(Long componentId) { + public static ComponentViewCountIncreaseEvent from(final Component component) { + return new ComponentViewCountIncreaseEvent(component.getId()); + } +} diff --git a/src/main/java/ject/componote/domain/component/dto/find/request/ComponentSearchRequest.java b/src/main/java/ject/componote/domain/component/dto/find/request/ComponentSearchRequest.java new file mode 100644 index 00000000..a25eac35 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dto/find/request/ComponentSearchRequest.java @@ -0,0 +1,12 @@ +package ject.componote.domain.component.dto.find.request; + +import jakarta.annotation.Nullable; +import jakarta.validation.constraints.NotBlank; +import ject.componote.domain.component.domain.ComponentType; + +import java.util.List; + +public record ComponentSearchRequest( + @NotBlank String keyword, + @Nullable List types) { +} diff --git a/src/main/java/ject/componote/domain/component/dto/find/response/ComponentBlockResponse.java b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentBlockResponse.java new file mode 100644 index 00000000..5c4d63c2 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentBlockResponse.java @@ -0,0 +1,12 @@ +package ject.componote.domain.component.dto.find.response; + +import ject.componote.domain.component.domain.block.ContentBlock; + +public record ComponentBlockResponse(Integer order, String content) { + public static ComponentBlockResponse from(final ContentBlock contentBlock) { + return new ComponentBlockResponse( + contentBlock.getOrder(), + contentBlock.getValue() + ); + } +} diff --git a/src/main/java/ject/componote/domain/component/dto/find/response/ComponentDetailResponse.java b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentDetailResponse.java new file mode 100644 index 00000000..54d60341 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentDetailResponse.java @@ -0,0 +1,20 @@ +package ject.componote.domain.component.dto.find.response; + +import ject.componote.domain.component.domain.block.BlockType; + +import java.util.List; +import java.util.Map; + +public record ComponentDetailResponse( + String title, + List mixedNames, + String description, + Long commentCount, + Long bookmarkCount, + Long designReferenceCount, + String thumbnailUrl, + Map> blocks, + Boolean isBookmarked +) { + +} diff --git a/src/main/java/ject/componote/domain/component/dto/find/response/ComponentSummaryResponse.java b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentSummaryResponse.java new file mode 100644 index 00000000..18d6b474 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/dto/find/response/ComponentSummaryResponse.java @@ -0,0 +1,30 @@ +package ject.componote.domain.component.dto.find.response; + +import ject.componote.domain.component.dao.ComponentSummaryDao; +import ject.componote.domain.component.domain.summary.ComponentSummary; + +public record ComponentSummaryResponse( + Long id, + String thumbnailUrl, + String title, + String description, + String type, + Long bookmarkCount, + Long commentCount, + Long designReferenceCount, + Boolean isBookmarked) { + public static ComponentSummaryResponse from(final ComponentSummaryDao dao) { + final ComponentSummary summary = dao.summary(); + return new ComponentSummaryResponse( + dao.id(), + summary.getThumbnail().toUrl(), + summary.getTitle(), + summary.getDescription(), + dao.type().name(), + dao.bookmarkCount().getValue(), + dao.commentCount().getValue(), + dao.designReferenceCount().getValue(), + dao.isBookmarked() + ); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/ComponentException.java b/src/main/java/ject/componote/domain/component/error/ComponentException.java index dc25293b..75e488d8 100644 --- a/src/main/java/ject/componote/domain/component/error/ComponentException.java +++ b/src/main/java/ject/componote/domain/component/error/ComponentException.java @@ -1,4 +1,10 @@ package ject.componote.domain.component.error; -public class ComponentException { +import ject.componote.global.error.ComponoteException; +import org.springframework.http.HttpStatus; + +public class ComponentException extends ComponoteException { + public ComponentException(final String message, final HttpStatus status) { + super(message, status); + } } diff --git a/src/main/java/ject/componote/domain/component/error/InvalidCommentSearchStrategyException.java b/src/main/java/ject/componote/domain/component/error/InvalidCommentSearchStrategyException.java new file mode 100644 index 00000000..6b7e2d2d --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/InvalidCommentSearchStrategyException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class InvalidCommentSearchStrategyException extends ComponentException { + public InvalidCommentSearchStrategyException() { + super("컴포넌트 검색에 실패했습니다. 요청 Body를 확인해주세요.", HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/InvalidComponentImageExtensionException.java b/src/main/java/ject/componote/domain/component/error/InvalidComponentImageExtensionException.java new file mode 100644 index 00000000..dc6094ba --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/InvalidComponentImageExtensionException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class InvalidComponentImageExtensionException extends ComponentException { + public InvalidComponentImageExtensionException(final String extension) { + super("확장자가 올바르지 않습니다. 입력된 확장자: " + extension, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/InvalidComponentThumbnailExtensionException.java b/src/main/java/ject/componote/domain/component/error/InvalidComponentThumbnailExtensionException.java new file mode 100644 index 00000000..beae5f50 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/InvalidComponentThumbnailExtensionException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class InvalidComponentThumbnailExtensionException extends ComponentException { + public InvalidComponentThumbnailExtensionException(final String extension) { + super("확장자가 올바르지 않습니다. 입력된 확장자: " + extension, HttpStatus.BAD_REQUEST); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/NotFoundComponentException.java b/src/main/java/ject/componote/domain/component/error/NotFoundComponentException.java new file mode 100644 index 00000000..0785f8f0 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/NotFoundComponentException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class NotFoundComponentException extends ComponentException { + public NotFoundComponentException(final Long componentId) { + super("컴포넌트를 찾을 수 없습니다. 컴포넌트 ID: " + componentId, HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/NotFoundComponentImageException.java b/src/main/java/ject/componote/domain/component/error/NotFoundComponentImageException.java new file mode 100644 index 00000000..6be9b2e1 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/NotFoundComponentImageException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class NotFoundComponentImageException extends ComponentException { + public NotFoundComponentImageException() { + super("이미지 objectKey를 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/ject/componote/domain/component/error/NotFoundComponentThumbnailException.java b/src/main/java/ject/componote/domain/component/error/NotFoundComponentThumbnailException.java new file mode 100644 index 00000000..9cf76986 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/error/NotFoundComponentThumbnailException.java @@ -0,0 +1,9 @@ +package ject.componote.domain.component.error; + +import org.springframework.http.HttpStatus; + +public class NotFoundComponentThumbnailException extends ComponentException { + public NotFoundComponentThumbnailException() { + super("이미지 objectKey를 찾을 수 없습니다.", HttpStatus.NOT_FOUND); + } +} diff --git a/src/main/java/ject/componote/domain/component/model/ComponentImage.java b/src/main/java/ject/componote/domain/component/model/ComponentImage.java new file mode 100644 index 00000000..69faab21 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/model/ComponentImage.java @@ -0,0 +1,35 @@ +package ject.componote.domain.component.model; + +import ject.componote.domain.common.model.AbstractImage; +import ject.componote.domain.component.error.InvalidComponentImageExtensionException; +import ject.componote.domain.component.error.NotFoundComponentImageException; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@ToString +public class ComponentImage extends AbstractImage { + // Arrays.asList 로 만든 List: contains(null) 시 NPE 발생하지 않고 false 리턴 + private static final List ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("png"); + + public ComponentImage(final String objectKey) { + super(objectKey); + } + + public static ComponentImage from(final String objectKey) { + if (objectKey == null || objectKey.isEmpty()) { + throw new NotFoundComponentImageException(); + } + + final String extension = StringUtils.getFilenameExtension(objectKey); + if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) { + throw new InvalidComponentImageExtensionException(extension); + } + + return new ComponentImage(objectKey); + } +} diff --git a/src/main/java/ject/componote/domain/component/model/ComponentThumbnail.java b/src/main/java/ject/componote/domain/component/model/ComponentThumbnail.java new file mode 100644 index 00000000..a8f6a5e1 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/model/ComponentThumbnail.java @@ -0,0 +1,34 @@ +package ject.componote.domain.component.model; + +import ject.componote.domain.common.model.AbstractImage; +import ject.componote.domain.component.error.InvalidComponentThumbnailExtensionException; +import ject.componote.domain.component.error.NotFoundComponentThumbnailException; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.List; + +@EqualsAndHashCode(callSuper = true) +@ToString +public class ComponentThumbnail extends AbstractImage { + private static final List ALLOWED_IMAGE_EXTENSIONS = Arrays.asList("jpg", "jpeg", "png"); + + public ComponentThumbnail(final String objectKey) { + super(objectKey); + } + + public static ComponentThumbnail from(final String objectKey) { + if (objectKey == null || objectKey.isEmpty()) { + throw new NotFoundComponentThumbnailException(); + } + + final String extension = StringUtils.getFilenameExtension(objectKey); + if (!ALLOWED_IMAGE_EXTENSIONS.contains(extension)) { + throw new InvalidComponentThumbnailExtensionException(extension); + } + + return new ComponentThumbnail(objectKey); + } +} diff --git a/src/main/java/ject/componote/domain/component/model/converter/ComponentImageConverter.java b/src/main/java/ject/componote/domain/component/model/converter/ComponentImageConverter.java new file mode 100644 index 00000000..217c2ec7 --- /dev/null +++ b/src/main/java/ject/componote/domain/component/model/converter/ComponentImageConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.component.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.component.model.ComponentImage; + +@Converter +public class ComponentImageConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final ComponentImage attribute) { + return attribute.getObjectKey(); + } + + @Override + public ComponentImage convertToEntityAttribute(final String dbData) { + return ComponentImage.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/component/model/converter/ComponentThumbnailConverter.java b/src/main/java/ject/componote/domain/component/model/converter/ComponentThumbnailConverter.java new file mode 100644 index 00000000..799c45ca --- /dev/null +++ b/src/main/java/ject/componote/domain/component/model/converter/ComponentThumbnailConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.component.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.component.model.ComponentThumbnail; + +@Converter +public class ComponentThumbnailConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final ComponentThumbnail attribute) { + return attribute.getObjectKey(); + } + + @Override + public ComponentThumbnail convertToEntityAttribute(final String dbData) { + return ComponentThumbnail.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/component/util/ComponentMapper.java b/src/main/java/ject/componote/domain/component/util/ComponentMapper.java new file mode 100644 index 00000000..1448d4fd --- /dev/null +++ b/src/main/java/ject/componote/domain/component/util/ComponentMapper.java @@ -0,0 +1,47 @@ +package ject.componote.domain.component.util; + +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.domain.MixedName; +import ject.componote.domain.component.domain.block.BlockType; +import ject.componote.domain.component.domain.block.ContentBlock; +import ject.componote.domain.component.domain.summary.ComponentSummary; +import ject.componote.domain.component.dto.find.response.ComponentBlockResponse; +import ject.componote.domain.component.dto.find.response.ComponentDetailResponse; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@org.springframework.stereotype.Component +public class ComponentMapper { + public ComponentDetailResponse mapFrom(final Component component, final Boolean isBookmarked) { + final ComponentSummary summary = component.getSummary(); + return new ComponentDetailResponse( + summary.getTitle(), + parseMixedNames(component), + summary.getDescription(), + component.getCommentCount().getValue(), + component.getBookmarkCount().getValue(), + component.getDesignReferenceCount().getValue(), + summary.getThumbnail().toUrl(), + parseBlocks(component), + isBookmarked + ); + } + + private List parseMixedNames(final Component component) { + return component.getMixedNames() + .stream() + .map(MixedName::getName) + .toList(); + } + + private Map> parseBlocks(final Component component) { + final List contentBlocks = component.getContentBlocks(); + return contentBlocks.stream() + .collect(Collectors.groupingBy( + ContentBlock::getType, + Collectors.mapping(ComponentBlockResponse::from, Collectors.toList())) + ); + } +} diff --git a/src/main/java/ject/componote/domain/faq/api/FAQController.java b/src/main/java/ject/componote/domain/faq/api/FAQController.java new file mode 100644 index 00000000..4207867c --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/api/FAQController.java @@ -0,0 +1,30 @@ +package ject.componote.domain.faq.api; + +import jakarta.validation.Valid; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.faq.application.FAQService; +import ject.componote.domain.faq.dto.request.FAQRequest; +import ject.componote.domain.faq.dto.response.FAQResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequestMapping("/faqs") +@RestController +@RequiredArgsConstructor +public class FAQController { + private final FAQService faqService; + + @GetMapping + public ResponseEntity> getFAQs(@ModelAttribute @Valid final FAQRequest faqRequest, + @PageableDefault final Pageable pageable) { + return ResponseEntity.ok( + faqService.getFAQs(faqRequest, pageable) + ); + } +} diff --git a/src/main/java/ject/componote/domain/faq/api/FAQTypeConstant.java b/src/main/java/ject/componote/domain/faq/api/FAQTypeConstant.java new file mode 100644 index 00000000..71ef4731 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/api/FAQTypeConstant.java @@ -0,0 +1,23 @@ +package ject.componote.domain.faq.api; + +import ject.componote.domain.faq.domain.FAQType; +import lombok.Getter; + +@Getter +public enum FAQTypeConstant { + ALL(null), + COMPONENT(FAQType.COMPONENT), + DESIGN(FAQType.DESIGN), + SERVICE(FAQType.SERVICE), + ETC(FAQType.ETC); + + private final FAQType type; + + FAQTypeConstant(final FAQType type) { + this.type = type; + } + + public boolean isAll() { + return this == ALL; + } +} diff --git a/src/main/java/ject/componote/domain/faq/application/FAQService.java b/src/main/java/ject/componote/domain/faq/application/FAQService.java new file mode 100644 index 00000000..18a84087 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/application/FAQService.java @@ -0,0 +1,37 @@ +package ject.componote.domain.faq.application; + +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.faq.api.FAQTypeConstant; +import ject.componote.domain.faq.dao.FAQRepository; +import ject.componote.domain.faq.domain.FAQ; +import ject.componote.domain.faq.domain.FAQType; +import ject.componote.domain.faq.dto.request.FAQRequest; +import ject.componote.domain.faq.dto.response.FAQResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class FAQService { + private final FAQRepository faqRepository; + + public PageResponse getFAQs(final FAQRequest request, final Pageable pageable) { + final FAQTypeConstant typeConstant = request.type(); + final Page page = findAllFAQsWithConditions(typeConstant, pageable) + .map(FAQResponse::from); + return PageResponse.from(page); + } + + private Page findAllFAQsWithConditions(final FAQTypeConstant typeConstant, final Pageable pageable) { + if (typeConstant.isAll()) { + return faqRepository.findAll(pageable); + } + + final FAQType type = typeConstant.getType(); + return faqRepository.findAllByType(type, pageable); + } +} diff --git a/src/main/java/ject/componote/domain/faq/dao/FAQRepository.java b/src/main/java/ject/componote/domain/faq/dao/FAQRepository.java new file mode 100644 index 00000000..43935b5b --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/dao/FAQRepository.java @@ -0,0 +1,11 @@ +package ject.componote.domain.faq.dao; + +import ject.componote.domain.faq.domain.FAQ; +import ject.componote.domain.faq.domain.FAQType; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FAQRepository extends JpaRepository { + Page findAllByType(final FAQType type, final Pageable pageable); +} diff --git a/src/main/java/ject/componote/domain/announcement/domain/FAQ.java b/src/main/java/ject/componote/domain/faq/domain/FAQ.java similarity index 60% rename from src/main/java/ject/componote/domain/announcement/domain/FAQ.java rename to src/main/java/ject/componote/domain/faq/domain/FAQ.java index c26bd178..40a4c846 100644 --- a/src/main/java/ject/componote/domain/announcement/domain/FAQ.java +++ b/src/main/java/ject/componote/domain/faq/domain/FAQ.java @@ -1,4 +1,4 @@ -package ject.componote.domain.announcement.domain; +package ject.componote.domain.faq.domain; import jakarta.persistence.Column; import jakarta.persistence.Convert; @@ -8,11 +8,11 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import ject.componote.domain.announcement.model.Description; -import ject.componote.domain.announcement.model.Title; -import ject.componote.domain.announcement.model.converter.DescriptionConverter; -import ject.componote.domain.announcement.model.converter.TitleConverter; +import ject.componote.domain.faq.model.FAQContent; +import ject.componote.domain.faq.model.FAQTitle; +import ject.componote.domain.faq.model.converter.FAQContentConverter; import ject.componote.domain.common.domain.BaseEntity; +import ject.componote.domain.faq.model.converter.FAQTitleConverter; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -31,11 +31,11 @@ public class FAQ extends BaseEntity { @Enumerated(EnumType.STRING) private FAQType type; - @Convert(converter = TitleConverter.class) + @Convert(converter = FAQTitleConverter.class) @Column(name = "title", nullable = false) - private Title title; + private FAQTitle title; - @Convert(converter = DescriptionConverter.class) - @Column(name = "description", nullable = false) - private Description description; + @Convert(converter = FAQContentConverter.class) + @Column(name = "content", nullable = false) + private FAQContent content; } diff --git a/src/main/java/ject/componote/domain/announcement/domain/FAQType.java b/src/main/java/ject/componote/domain/faq/domain/FAQType.java similarity index 54% rename from src/main/java/ject/componote/domain/announcement/domain/FAQType.java rename to src/main/java/ject/componote/domain/faq/domain/FAQType.java index 795ff75a..72e108b5 100644 --- a/src/main/java/ject/componote/domain/announcement/domain/FAQType.java +++ b/src/main/java/ject/componote/domain/faq/domain/FAQType.java @@ -1,4 +1,4 @@ -package ject.componote.domain.announcement.domain; +package ject.componote.domain.faq.domain; public enum FAQType { COMPONENT, DESIGN, SERVICE, ETC; diff --git a/src/main/java/ject/componote/domain/faq/dto/request/FAQRequest.java b/src/main/java/ject/componote/domain/faq/dto/request/FAQRequest.java new file mode 100644 index 00000000..cab17070 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/dto/request/FAQRequest.java @@ -0,0 +1,7 @@ +package ject.componote.domain.faq.dto.request; + +import jakarta.validation.constraints.NotNull; +import ject.componote.domain.faq.api.FAQTypeConstant; + +public record FAQRequest(@NotNull FAQTypeConstant type) { +} diff --git a/src/main/java/ject/componote/domain/faq/dto/response/FAQResponse.java b/src/main/java/ject/componote/domain/faq/dto/response/FAQResponse.java new file mode 100644 index 00000000..60daad2a --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/dto/response/FAQResponse.java @@ -0,0 +1,9 @@ +package ject.componote.domain.faq.dto.response; + +import ject.componote.domain.faq.domain.FAQ; + +public record FAQResponse(String title, String content) { + public static FAQResponse from(final FAQ faq) { + return new FAQResponse(faq.getTitle().getValue(), faq.getContent().getValue()); + } +} diff --git a/src/main/java/ject/componote/domain/faq/error/FAQException.java b/src/main/java/ject/componote/domain/faq/error/FAQException.java new file mode 100644 index 00000000..508ba7f5 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/error/FAQException.java @@ -0,0 +1,4 @@ +package ject.componote.domain.faq.error; + +public class FAQException { +} diff --git a/src/main/java/ject/componote/domain/faq/model/FAQContent.java b/src/main/java/ject/componote/domain/faq/model/FAQContent.java new file mode 100644 index 00000000..09719da0 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/model/FAQContent.java @@ -0,0 +1,20 @@ +package ject.componote.domain.faq.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@EqualsAndHashCode +@Getter +@ToString +public class FAQContent { + private final String value; + + private FAQContent(final String value) { + this.value = value; + } + + public static FAQContent from(final String value) { + return new FAQContent(value); + } +} diff --git a/src/main/java/ject/componote/domain/faq/model/FAQTitle.java b/src/main/java/ject/componote/domain/faq/model/FAQTitle.java new file mode 100644 index 00000000..1059e17f --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/model/FAQTitle.java @@ -0,0 +1,20 @@ +package ject.componote.domain.faq.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@Getter +@EqualsAndHashCode +@ToString +public class FAQTitle { + private final String value; + + private FAQTitle(final String value) { + this.value = value; + } + + public static FAQTitle from(final String value) { + return new FAQTitle(value); + } +} diff --git a/src/main/java/ject/componote/domain/faq/model/converter/FAQContentConverter.java b/src/main/java/ject/componote/domain/faq/model/converter/FAQContentConverter.java new file mode 100644 index 00000000..f62c23cf --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/model/converter/FAQContentConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.faq.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.faq.model.FAQContent; + +@Converter +public class FAQContentConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final FAQContent attribute) { + return attribute.getValue(); + } + + @Override + public FAQContent convertToEntityAttribute(final String dbData) { + return FAQContent.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/faq/model/converter/FAQTitleConverter.java b/src/main/java/ject/componote/domain/faq/model/converter/FAQTitleConverter.java new file mode 100644 index 00000000..66f37443 --- /dev/null +++ b/src/main/java/ject/componote/domain/faq/model/converter/FAQTitleConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.faq.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.faq.model.FAQTitle; + +@Converter +public class FAQTitleConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final FAQTitle attribute) { + return attribute.getValue(); + } + + @Override + public FAQTitle convertToEntityAttribute(final String dbData) { + return FAQTitle.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/notice/api/NoticeController.java b/src/main/java/ject/componote/domain/notice/api/NoticeController.java new file mode 100644 index 00000000..25d8c3ff --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/api/NoticeController.java @@ -0,0 +1,26 @@ +package ject.componote.domain.notice.api; + +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.notice.application.NoticeService; +import ject.componote.domain.notice.dto.response.NoticeResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/notices") +public class NoticeController { + private final NoticeService noticeService; + + @GetMapping + public ResponseEntity> getNotices(@PageableDefault final Pageable pageable) { + return ResponseEntity.ok( + noticeService.getNotices(pageable) + ); + } +} diff --git a/src/main/java/ject/componote/domain/notice/application/NoticeService.java b/src/main/java/ject/componote/domain/notice/application/NoticeService.java new file mode 100644 index 00000000..f423961b --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/application/NoticeService.java @@ -0,0 +1,24 @@ +package ject.componote.domain.notice.application; + +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.notice.dao.NoticeRepository; +import ject.componote.domain.notice.dto.response.NoticeResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class NoticeService { + private final NoticeRepository noticeRepository; + + public PageResponse getNotices(final Pageable pageable) { + final Page page = noticeRepository.findAll(pageable) + .map(NoticeResponse::from); + return PageResponse.from(page); + } +} diff --git a/src/main/java/ject/componote/domain/announcement/domain/NoticeRepository.java b/src/main/java/ject/componote/domain/notice/dao/NoticeRepository.java similarity index 59% rename from src/main/java/ject/componote/domain/announcement/domain/NoticeRepository.java rename to src/main/java/ject/componote/domain/notice/dao/NoticeRepository.java index e1a72c4e..878b0a91 100644 --- a/src/main/java/ject/componote/domain/announcement/domain/NoticeRepository.java +++ b/src/main/java/ject/componote/domain/notice/dao/NoticeRepository.java @@ -1,5 +1,6 @@ -package ject.componote.domain.announcement.domain; +package ject.componote.domain.notice.dao; +import ject.componote.domain.notice.domain.Notice; import org.springframework.data.jpa.repository.JpaRepository; public interface NoticeRepository extends JpaRepository { diff --git a/src/main/java/ject/componote/domain/announcement/domain/Notice.java b/src/main/java/ject/componote/domain/notice/domain/Notice.java similarity index 55% rename from src/main/java/ject/componote/domain/announcement/domain/Notice.java rename to src/main/java/ject/componote/domain/notice/domain/Notice.java index 742b7212..9951c431 100644 --- a/src/main/java/ject/componote/domain/announcement/domain/Notice.java +++ b/src/main/java/ject/componote/domain/notice/domain/Notice.java @@ -1,4 +1,4 @@ -package ject.componote.domain.announcement.domain; +package ject.componote.domain.notice.domain; import jakarta.persistence.Column; import jakarta.persistence.Convert; @@ -6,11 +6,11 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import ject.componote.domain.announcement.model.Description; -import ject.componote.domain.announcement.model.Title; -import ject.componote.domain.announcement.model.converter.DescriptionConverter; -import ject.componote.domain.announcement.model.converter.TitleConverter; import ject.componote.domain.common.domain.BaseEntity; +import ject.componote.domain.notice.model.NoticeContent; +import ject.componote.domain.notice.model.NoticeTitle; +import ject.componote.domain.notice.model.converter.NoticeContentConverter; +import ject.componote.domain.notice.model.converter.NoticeTitleConverter; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -25,11 +25,11 @@ public class Notice extends BaseEntity { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Convert(converter = TitleConverter.class) + @Convert(converter = NoticeTitleConverter.class) @Column(name = "title", nullable = false) - private Title title; + private NoticeTitle title; - @Convert(converter = DescriptionConverter.class) - @Column(name = "description", nullable = false) - private Description description; + @Convert(converter = NoticeContentConverter.class) + @Column(name = "content", nullable = false) + private NoticeContent content; } diff --git a/src/main/java/ject/componote/domain/notice/dto/response/NoticeResponse.java b/src/main/java/ject/componote/domain/notice/dto/response/NoticeResponse.java new file mode 100644 index 00000000..3582095b --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/dto/response/NoticeResponse.java @@ -0,0 +1,11 @@ +package ject.componote.domain.notice.dto.response; + +import ject.componote.domain.notice.domain.Notice; + +import java.time.LocalDate; + +public record NoticeResponse(String title, String content, LocalDate createdDate) { + public static NoticeResponse from(final Notice notice) { + return new NoticeResponse(notice.getTitle().getValue(), notice.getContent().getValue(), notice.getCreatedAt().toLocalDate()); + } +} diff --git a/src/main/java/ject/componote/domain/notice/error/NoticeException.java b/src/main/java/ject/componote/domain/notice/error/NoticeException.java new file mode 100644 index 00000000..3db3cda7 --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/error/NoticeException.java @@ -0,0 +1,4 @@ +package ject.componote.domain.notice.error; + +public class NoticeException { +} diff --git a/src/main/java/ject/componote/domain/notice/model/NoticeContent.java b/src/main/java/ject/componote/domain/notice/model/NoticeContent.java new file mode 100644 index 00000000..83e774b2 --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/model/NoticeContent.java @@ -0,0 +1,20 @@ +package ject.componote.domain.notice.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@EqualsAndHashCode +@Getter +@ToString +public class NoticeContent { + private final String value; + + private NoticeContent(final String value) { + this.value = value; + } + + public static NoticeContent from(final String value) { + return new NoticeContent(value); + } +} diff --git a/src/main/java/ject/componote/domain/notice/model/NoticeTitle.java b/src/main/java/ject/componote/domain/notice/model/NoticeTitle.java new file mode 100644 index 00000000..6112dc80 --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/model/NoticeTitle.java @@ -0,0 +1,20 @@ +package ject.componote.domain.notice.model; + +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; + +@EqualsAndHashCode +@Getter +@ToString +public class NoticeTitle { + private final String value; + + private NoticeTitle(final String value) { + this.value = value; + } + + public static NoticeTitle from(final String value) { + return new NoticeTitle(value); + } +} diff --git a/src/main/java/ject/componote/domain/notice/model/converter/NoticeContentConverter.java b/src/main/java/ject/componote/domain/notice/model/converter/NoticeContentConverter.java new file mode 100644 index 00000000..16c91f3f --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/model/converter/NoticeContentConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.notice.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.notice.model.NoticeContent; + +@Converter +public class NoticeContentConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final NoticeContent attribute) { + return attribute.getValue(); + } + + @Override + public NoticeContent convertToEntityAttribute(final String dbData) { + return NoticeContent.from(dbData); + } +} diff --git a/src/main/java/ject/componote/domain/notice/model/converter/NoticeTitleConverter.java b/src/main/java/ject/componote/domain/notice/model/converter/NoticeTitleConverter.java new file mode 100644 index 00000000..abc330d6 --- /dev/null +++ b/src/main/java/ject/componote/domain/notice/model/converter/NoticeTitleConverter.java @@ -0,0 +1,18 @@ +package ject.componote.domain.notice.model.converter; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import ject.componote.domain.notice.model.NoticeTitle; + +@Converter +public class NoticeTitleConverter implements AttributeConverter { + @Override + public String convertToDatabaseColumn(final NoticeTitle attribute) { + return attribute.getValue(); + } + + @Override + public NoticeTitle convertToEntityAttribute(final String dbData) { + return NoticeTitle.from(dbData); + } +} diff --git a/src/main/java/ject/componote/global/config/AsyncConfig.java b/src/main/java/ject/componote/global/config/AsyncConfig.java new file mode 100644 index 00000000..3211ed5a --- /dev/null +++ b/src/main/java/ject/componote/global/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package ject.componote.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} diff --git a/src/main/java/ject/componote/global/config/QueryDslConfig.java b/src/main/java/ject/componote/global/config/QueryDslConfig.java new file mode 100644 index 00000000..af0989e7 --- /dev/null +++ b/src/main/java/ject/componote/global/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package ject.componote.global.config; + +import com.querydsl.jpa.JPQLTemplates; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(JPQLTemplates.DEFAULT, entityManager); + } +} diff --git a/src/main/java/ject/componote/global/error/GlobalExceptionHandler.java b/src/main/java/ject/componote/global/error/GlobalExceptionHandler.java index 4bcdd00d..b9ed55c3 100644 --- a/src/main/java/ject/componote/global/error/GlobalExceptionHandler.java +++ b/src/main/java/ject/componote/global/error/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import jakarta.validation.ConstraintViolationException; import ject.componote.infra.error.InfraException; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.HttpRequestMethodNotSupportedException; @@ -38,6 +39,12 @@ public ResponseEntity handleSQLException(final SQLException excep .body(ErrorResponse.of(INTERNAL_SERVER_ERROR, "SQL 오류입니다.")); } + @ExceptionHandler(DataAccessException.class) + public ResponseEntity handleDataAccessException(final DataAccessException exception) { + return ResponseEntity.status(BAD_REQUEST) + .body(ErrorResponse.of(BAD_REQUEST, exception.getLocalizedMessage())); + } + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) public ResponseEntity handleRequestMethodNotSupportedException(final HttpRequestMethodNotSupportedException exception) { return ResponseEntity.status(METHOD_NOT_ALLOWED) diff --git a/src/main/java/ject/componote/global/util/RepositoryUtils.java b/src/main/java/ject/componote/global/util/RepositoryUtils.java new file mode 100644 index 00000000..c6bd0840 --- /dev/null +++ b/src/main/java/ject/componote/global/util/RepositoryUtils.java @@ -0,0 +1,82 @@ +package ject.componote.global.util; + +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.ComparableExpression; +import com.querydsl.core.types.dsl.EntityPathBase; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.PathBuilder; +import com.querydsl.core.types.dsl.SimpleExpression; +import com.querydsl.jpa.impl.JPAQuery; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.support.PageableExecutionUtils; + +public final class RepositoryUtils { + private RepositoryUtils() { + + } + + public static Page toPage(final JPAQuery baseQuery, + final JPAQuery countQuery, + final EntityPathBase qClass, + final Pageable pageable) { + if (countQuery.fetchFirst() == null) { + return Page.empty(); + } + + final JPAQuery contentQuery = createContentQuery(baseQuery, qClass, pageable); + return PageableExecutionUtils.getPage(contentQuery.fetch(), pageable, countQuery::fetchOne); + } + + public static > BooleanExpression eqExpression(final SimpleExpression simpleExpression, final T target) { + if (target == null) { + return null; + } + + return simpleExpression.eq(target); + } + + public static > BooleanExpression eqExpression(final NumberExpression numberExpression, final T target) { + if (target == null) { + return null; + } + + return numberExpression.eq(target); + } + + public static > BooleanExpression eqExpression(final SimpleExpression simpleExpression, final SimpleExpression target) { + return simpleExpression.eq(target); + } + + private static JPAQuery createContentQuery(final JPAQuery query, + final EntityPathBase qClass, + final Pageable pageable) { + return query.limit(pageable.getPageSize()) + .offset(pageable.getOffset()) + .orderBy(createOrderSpecifiers(qClass, pageable)); + } + + private static OrderSpecifier[] createOrderSpecifiers(final EntityPathBase qClass, final Pageable pageable) { + return pageable.getSort() + .stream() + .map(sort -> toOrderSpecifier(qClass, sort)) + .toArray(OrderSpecifier[]::new); + } + + private static OrderSpecifier toOrderSpecifier(final EntityPathBase qClass, final Sort.Order sortOrder) { + final Order orderMethod = toOrder(sortOrder); + final PathBuilder pathBuilder = new PathBuilder<>(qClass.getType(), qClass.getMetadata()); + return new OrderSpecifier(orderMethod, pathBuilder.get(sortOrder.getProperty())); + } + + private static Order toOrder(final Sort.Order sortOrder) { + if (sortOrder.isAscending()) { + return Order.ASC; + } + + return Order.DESC; + } +} diff --git a/src/main/java/ject/componote/infra/file/application/FileClient.java b/src/main/java/ject/componote/infra/file/application/FileClient.java index 5e2a58db..3b0de4b2 100644 --- a/src/main/java/ject/componote/infra/file/application/FileClient.java +++ b/src/main/java/ject/componote/infra/file/application/FileClient.java @@ -1,8 +1,8 @@ package ject.componote.infra.file.application; -import ject.componote.global.error.ErrorResponse; import ject.componote.infra.file.dto.move.request.MoveRequest; import ject.componote.infra.file.error.FileClientException; +import ject.componote.infra.file.error.FileServerErrorResponse; import ject.componote.infra.util.TimeoutDecorator; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -23,10 +23,10 @@ public class FileClient { private final TimeoutDecorator timeoutDecorator; private final WebClient webClient; - public FileClient(@Value("${file.max-retry}") final int maxRetry, - @Value("${file.timeout}") final int timeout, - @Value("${file.client.move.method}") final HttpMethod method, - @Value("${file.client.move.uri}") final String uri, + public FileClient(@Value("${storage.max-retry}") final int maxRetry, + @Value("${storage.timeout}") final int timeout, + @Value("${storage.client.move.method}") final HttpMethod method, + @Value("${storage.client.move.uri}") final String uri, final TimeoutDecorator timeoutDecorator, final WebClient webClient) { this.maxRetry = maxRetry; @@ -59,13 +59,13 @@ private Mono moveImage(final String objectKey) { } private Mono handle5xxError(final ClientResponse clientResponse) { - return clientResponse.bodyToMono(ErrorResponse.class) - .map(ErrorResponse::getMessage) + return clientResponse.bodyToMono(FileServerErrorResponse.class) + .map(FileServerErrorResponse::getMessage) .map(IllegalStateException::new); } private Mono handle4xxError(final ClientResponse clientResponse) { - return clientResponse.bodyToMono(ErrorResponse.class) + return clientResponse.bodyToMono(FileServerErrorResponse.class) .map(FileClientException::new); } diff --git a/src/main/java/ject/componote/infra/file/application/FileService.java b/src/main/java/ject/componote/infra/file/application/FileService.java index 396a38be..4f734fd3 100644 --- a/src/main/java/ject/componote/infra/file/application/FileService.java +++ b/src/main/java/ject/componote/infra/file/application/FileService.java @@ -1,6 +1,6 @@ package ject.componote.infra.file.application; -import ject.componote.domain.common.model.BaseImage; +import ject.componote.domain.common.model.AbstractImage; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -12,7 +12,7 @@ public class FileService { private final FileClient fileClient; - public void moveImage(final BaseImage image) { + public void moveImage(final AbstractImage image) { if (image.isEmpty()) { log.warn("No image to move"); return; diff --git a/src/main/java/ject/componote/infra/file/error/FileClientException.java b/src/main/java/ject/componote/infra/file/error/FileClientException.java index 7821176f..04584bf5 100644 --- a/src/main/java/ject/componote/infra/file/error/FileClientException.java +++ b/src/main/java/ject/componote/infra/file/error/FileClientException.java @@ -1,11 +1,10 @@ package ject.componote.infra.file.error; -import ject.componote.global.error.ErrorResponse; import ject.componote.infra.error.InfraException; import org.springframework.http.HttpStatus; public class FileClientException extends InfraException { - public FileClientException(final ErrorResponse errorResponse) { - super(errorResponse.getMessage(), HttpStatus.valueOf(errorResponse.getStatus())); + public FileClientException(final FileServerErrorResponse response) { + super(response.getMessage(), HttpStatus.valueOf(response.getStatus())); } } diff --git a/src/main/java/ject/componote/infra/file/error/FileServerErrorResponse.java b/src/main/java/ject/componote/infra/file/error/FileServerErrorResponse.java new file mode 100644 index 00000000..05240b85 --- /dev/null +++ b/src/main/java/ject/componote/infra/file/error/FileServerErrorResponse.java @@ -0,0 +1,24 @@ +package ject.componote.infra.file.error; + +import ject.componote.global.error.ErrorResponse; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.http.HttpStatus; + +@AllArgsConstructor(access = AccessLevel.PUBLIC) +@NoArgsConstructor(access = AccessLevel.PRIVATE) +@Getter +public class FileServerErrorResponse { + private int status; + private String message; + + public static ErrorResponse of(final HttpStatus status, final String message) { + return new ErrorResponse(status.value(), message); + } + + public static ErrorResponse of(final HttpStatus status, final Exception exception) { + return new ErrorResponse(status.value(), exception.getMessage()); + } +} diff --git a/src/test/java/ject/componote/domain/auth/application/AuthServiceTest.java b/src/test/java/ject/componote/domain/auth/application/AuthServiceTest.java index b9835046..69989aee 100644 --- a/src/test/java/ject/componote/domain/auth/application/AuthServiceTest.java +++ b/src/test/java/ject/componote/domain/auth/application/AuthServiceTest.java @@ -52,7 +52,7 @@ class AuthServiceTest { Long socialAccountId = 1L; Member member = KIM.생성(socialAccountId); ProfileImage profileImage = member.getProfileImage(); - String profileImageObjectKey = profileImage.getImage().getObjectKey(); + String profileImageObjectKey = profileImage.getObjectKey(); @DisplayName("회원 가입") @Test @@ -74,7 +74,7 @@ public void signup() throws Exception { doReturn(member).when(memberRepository) .save(any()); doNothing().when(fileService) - .moveImage(profileImage.getImage()); + .moveImage(profileImage); final MemberSignupResponse actual = authService.signup(request); // then @@ -142,7 +142,7 @@ public void signupWhenMoveFail() throws Exception { doReturn(member).when(memberRepository) .save(any()); doThrow(FileClientException.class).when(fileService) - .moveImage(profileImage.getImage()); + .moveImage(profileImage); // then assertThatThrownBy(() -> authService.signup(request)) diff --git a/src/test/java/ject/componote/domain/auth/application/MemberServiceTest.java b/src/test/java/ject/componote/domain/auth/application/MemberServiceTest.java index e51e02aa..593a0932 100644 --- a/src/test/java/ject/componote/domain/auth/application/MemberServiceTest.java +++ b/src/test/java/ject/componote/domain/auth/application/MemberServiceTest.java @@ -77,7 +77,7 @@ public void updateProfileImage() throws Exception { doReturn(Optional.of(member)).when(memberRepository) .findById(memberId); doNothing().when(fileService) - .moveImage(newProfileImage.getImage()); + .moveImage(newProfileImage); memberService.updateProfileImage(authPrincipal, request); // then diff --git a/src/test/java/ject/componote/domain/comment/application/CommentCreationStrategyTest.java b/src/test/java/ject/componote/domain/comment/application/CommentCreationStrategyTest.java new file mode 100644 index 00000000..b5f8f038 --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/application/CommentCreationStrategyTest.java @@ -0,0 +1,32 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; +import ject.componote.fixture.CommentFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class CommentCreationStrategyTest { + @ParameterizedTest + @DisplayName("요청값에 알맞는 댓글 엔티티 생성") + @EnumSource(CommentFixture.class) + public void createBy(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final CommentCreateRequest createRequest = fixture.toCreateRequest(); + final Long memberId = comment.getMemberId(); + + // when + final Comment createdComment = CommentCreationStrategy.createBy(createRequest, memberId); + + // then + assertThat(createdComment.getContent().getValue()).isEqualTo(createRequest.content()); + assertThat(createdComment.getImage().getObjectKey()).isEqualTo(createRequest.imageObjectKey()); + assertThat(createdComment.getMemberId()).isEqualTo(memberId); + assertThat(createdComment.getComponentId()).isEqualTo(createRequest.componentId()); + assertThat(createdComment.getParentId()).isEqualTo(createRequest.parentId()); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/comment/application/CommentLikeEventListenerTest.java b/src/test/java/ject/componote/domain/comment/application/CommentLikeEventListenerTest.java new file mode 100644 index 00000000..e070a04c --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/application/CommentLikeEventListenerTest.java @@ -0,0 +1,119 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.auth.domain.Member; +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.comment.dao.CommentLikeRepository; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.like.event.CommentLikeEvent; +import ject.componote.domain.comment.dto.like.event.CommentUnlikeEvent; +import ject.componote.domain.comment.error.NotFoundCommentException; +import ject.componote.domain.common.model.Count; +import ject.componote.fixture.CommentFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static ject.componote.fixture.MemberFixture.KIM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class CommentLikeEventListenerTest { + @Mock + CommentRepository commentRepository; + + @Mock + CommentLikeRepository commentLikeRepository; + + @InjectMocks + CommentLikeEventListener commentLikeEventListener; + + final Member member = KIM.생성(1L); + final AuthPrincipal authPrincipal = AuthPrincipal.from(member); + + @ParameterizedTest + @DisplayName("댓글 좋아요 이벤트 처리") + @EnumSource(CommentFixture.class) + public void handleCommentLikeEvent(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Count previousLikeCount = comment.getLikeCount(); + final Long commentId = comment.getId(); + final CommentLikeEvent event = CommentLikeEvent.of(authPrincipal, commentId); + + // when + doReturn(Optional.of(comment)).when(commentRepository) + .findById(commentId); + commentLikeEventListener.handleCommentLikeEvent(event); + + // then + final Count newLikeCount = comment.getLikeCount(); + previousLikeCount.increase(); + assertThat(previousLikeCount).isEqualTo(newLikeCount); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요 이벤트 처리시 댓글 ID가 잘못된 경우 예외 발생") + @EnumSource(CommentFixture.class) + public void handleCommentLikeEventWhenInvalidCommentId(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final CommentLikeEvent event = CommentLikeEvent.of(authPrincipal, commentId); + + // when + doReturn(Optional.empty()).when(commentRepository) + .findById(commentId); + + // then + assertThatThrownBy(() -> commentLikeEventListener.handleCommentLikeEvent(event)) + .isInstanceOf(NotFoundCommentException.class); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요 취소 이벤트 처리") + @EnumSource(CommentFixture.class) + public void handleCommentUnLikeEvent(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Count previousLikeCount = comment.getLikeCount(); + final Long commentId = comment.getId(); + final CommentUnlikeEvent event = CommentUnlikeEvent.of(authPrincipal, commentId); + + // when + doReturn(Optional.of(comment)).when(commentRepository) + .findById(commentId); + commentLikeEventListener.handleCommentUnlikeEvent(event); + + // then + final Count newLikeCount = comment.getLikeCount(); + previousLikeCount.decrease(); + assertThat(previousLikeCount).isEqualTo(newLikeCount); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요 취소 이벤트 처리시 댓글 ID가 잘못된 경우 예외 발생") + @EnumSource(CommentFixture.class) + public void handleCommentUnlikeEventWhenInvalidCommentId(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final CommentUnlikeEvent event = CommentUnlikeEvent.of(authPrincipal, commentId); + + // when + doReturn(Optional.empty()).when(commentRepository) + .findById(commentId); + + // then + assertThatThrownBy(() -> commentLikeEventListener.handleCommentUnlikeEvent(event)) + .isInstanceOf(NotFoundCommentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/comment/application/CommentServiceTest.java b/src/test/java/ject/componote/domain/comment/application/CommentServiceTest.java new file mode 100644 index 00000000..bc08cd3b --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/application/CommentServiceTest.java @@ -0,0 +1,356 @@ +package ject.componote.domain.comment.application; + +import ject.componote.domain.auth.domain.Job; +import ject.componote.domain.auth.domain.Member; +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.auth.model.Nickname; +import ject.componote.domain.auth.model.ProfileImage; +import ject.componote.domain.comment.dao.CommentFindByComponentDao; +import ject.componote.domain.comment.dao.CommentFindByMemberDao; +import ject.componote.domain.comment.dao.CommentLikeRepository; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; +import ject.componote.domain.comment.dto.create.response.CommentCreateResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByComponentResponse; +import ject.componote.domain.comment.dto.find.response.CommentFindByMemberResponse; +import ject.componote.domain.comment.dto.like.event.CommentLikeEvent; +import ject.componote.domain.comment.dto.like.event.CommentUnlikeEvent; +import ject.componote.domain.comment.dto.update.request.CommentUpdateRequest; +import ject.componote.domain.comment.error.AlreadyLikedException; +import ject.componote.domain.comment.error.NoLikedException; +import ject.componote.domain.comment.error.NotFoundParentCommentException; +import ject.componote.domain.comment.model.CommentContent; +import ject.componote.domain.comment.model.CommentImage; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.common.model.Count; +import ject.componote.fixture.CommentFixture; +import ject.componote.infra.file.application.FileService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static ject.componote.fixture.CommentFixture.답글_이미지X; +import static ject.componote.fixture.MemberFixture.KIM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class CommentServiceTest { + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + CommentRepository commentRepository; + + @Mock + CommentLikeRepository commentLikeRepository; + + @Mock + FileService fileService; + + @InjectMocks + CommentService commentService; + + final Member member = KIM.생성(1L); + final AuthPrincipal authPrincipal = AuthPrincipal.from(member); + final Pageable pageable = PageRequest.of(0, 10); + + @ParameterizedTest + @DisplayName("댓글 생성") + @EnumSource(value = CommentFixture.class) + public void create(final CommentFixture fixture) throws Exception { + // given + final CommentCreateRequest createRequest = fixture.toCreateRequest(); + final Comment comment = fixture.생성(authPrincipal.id()); + final CommentCreateResponse expect = CommentCreateResponse.from(comment); + + // when + final Long parentId = comment.getParentId(); + if (parentId != null) { + doReturn(true).when(commentRepository) + .existsById(parentId); + } + + doReturn(comment).when(commentRepository) + .save(any()); + doNothing().when(fileService) + .moveImage(comment.getImage()); + final CommentCreateResponse actual = commentService.create(authPrincipal, createRequest); + + // then + assertThat(actual).isEqualTo(expect); + } + + @Test + @DisplayName("댓글 생성시 잘못된 parentId가 입력된 경우 예외 발생") + public void createWhenInvalidParentId() { + // given + final CommentCreateRequest createRequest = 답글_이미지X.toCreateRequest(); + final Long parentId = createRequest.parentId(); + + // when + doReturn(false).when(commentRepository) + .existsById(parentId); + // then + assertThatThrownBy(() -> commentService.create(authPrincipal, createRequest)) + .isInstanceOf(NotFoundParentCommentException.class); + } + + @Test + @DisplayName("마이 페이지 댓글 페이징 조회") + public void getCommentsByMemberId() throws Exception { + // given + final Long memberId = authPrincipal.id(); + final List content = List.of( + new CommentFindByMemberDao(1L, null, "컴포넌트 제목1", null, CommentContent.from("댓글 내용1"), LocalDateTime.now(), false), + new CommentFindByMemberDao(2L, 1L, "컴포넌트 제목2", CommentContent.from("댓글 내용1"), CommentContent.from("댓글 내용2"), LocalDateTime.now(), true) + ); + final Page page = new PageImpl<>(content, pageable, content.size()); + final PageResponse expect = PageResponse.from( + page.map(CommentFindByMemberResponse::from) + ); + + // when + doReturn(page).when(commentRepository) + .findAllByMemberIdWithPagination(memberId, pageable); + final PageResponse actual = commentService.getCommentsByMemberId(authPrincipal, pageable); + + // then + assertThat(actual).isEqualTo(expect); + } + + @Test + @DisplayName("비로그인 컴포넌트 댓글 페이징 조회") + public void getCommentsByComponentIdNoLoggedIn() throws Exception { + // given + final Long componentId = 1L; + final List content = List.of( + new CommentFindByComponentDao(1L, Nickname.from("닉네임1"), ProfileImage.from(null), Job.DEVELOPER, 1L, null, CommentImage.from(null), CommentContent.from("댓글 내용1"), LocalDateTime.now(), Count.create(), Count.create(), false, false), + new CommentFindByComponentDao(2L, Nickname.from("닉네임2"), ProfileImage.from(null), Job.DEVELOPER, 2L, 1L, CommentImage.from(null), CommentContent.from("댓글 내용2"), LocalDateTime.now(), Count.create(), Count.create(), false, true) + ); + final Page page = new PageImpl<>(content, pageable, content.size()); + final PageResponse expect = PageResponse.from( + page.map(CommentFindByComponentResponse::from) + ); + + // when + doReturn(page).when(commentRepository) + .findAllByComponentIdWithPagination(componentId, pageable); + final PageResponse actual = commentService.getCommentsByComponentId(null, componentId, pageable); + + // then + assertThat(actual).isEqualTo(expect); + } + + @Test + @DisplayName("로그인 컴포넌트 댓글 페이징 조회") + public void getCommentsByComponentIdWhenLoggedIn() throws Exception { + // given + final Long componentId = 1L; + final Long memberId = authPrincipal.id(); + final List content = List.of( + new CommentFindByComponentDao(1L, Nickname.from("닉네임1"), ProfileImage.from(null), Job.DEVELOPER, 1L, null, CommentImage.from(null), CommentContent.from("댓글 내용1"), LocalDateTime.now(), Count.create(), Count.create(), false, false), + new CommentFindByComponentDao(2L, Nickname.from("닉네임2"), ProfileImage.from(null), Job.DEVELOPER, 2L, 1L, CommentImage.from(null), CommentContent.from("댓글 내용2"), LocalDateTime.now(), Count.create(), Count.create(), false, true) + ); + final Page page = new PageImpl<>(content, pageable, content.size()); + final PageResponse expect = PageResponse.from( + page.map(CommentFindByComponentResponse::from) + ); + + // when + doReturn(page).when(commentRepository) + .findAllByComponentIdWithLikeStatusAndPagination(componentId, memberId, pageable); + final PageResponse actual = commentService.getCommentsByComponentId(authPrincipal, componentId, pageable); + + // then + assertThat(actual).isEqualTo(expect); + } + + @ParameterizedTest + @DisplayName("댓글 이미지만 수정") + @EnumSource(value = CommentFixture.class) + public void updateImage(final CommentFixture fixture) throws Exception { + // given + final Long memberId = authPrincipal.id(); + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final String content = comment.getContent().getValue(); + final String newObjectKey = "/comment/new.jpg"; + final CommentUpdateRequest request = new CommentUpdateRequest(newObjectKey, content); + + // when + doReturn(Optional.of(comment)).when(commentRepository) + .findByIdAndMemberId(commentId, memberId); + + // then + assertDoesNotThrow( + () -> commentService.update(authPrincipal, commentId, request) + ); + assertThat(comment.getImage().getObjectKey()).isEqualTo(newObjectKey); + } + + @ParameterizedTest + @DisplayName("댓글 내용만 수정") + @EnumSource(value = CommentFixture.class) + public void updateContent(final CommentFixture fixture) throws Exception { + // given + final Long memberId = authPrincipal.id(); + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final String objectKey = comment.getImage().getObjectKey(); + final String newContent = "수정된 내용"; + final CommentUpdateRequest request = new CommentUpdateRequest(objectKey, newContent); + + // when + doReturn(Optional.of(comment)).when(commentRepository) + .findByIdAndMemberId(commentId, memberId); + + // then + assertDoesNotThrow( + () -> commentService.update(authPrincipal, commentId, request) + ); + assertThat(comment.getContent().getValue()).isEqualTo(newContent); + } + + @ParameterizedTest + @DisplayName("댓글 이미지, 내용 모두 수정") + @EnumSource(value = CommentFixture.class) + public void updateAll(final CommentFixture fixture) throws Exception { + // given + final Long memberId = authPrincipal.id(); + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final String newContent = "수정된 내용"; + final String newObjectKey = "/comment/new.jpg"; + final CommentUpdateRequest request = new CommentUpdateRequest(newObjectKey, newContent); + + // when + doReturn(Optional.of(comment)).when(commentRepository) + .findByIdAndMemberId(commentId, memberId); + + // then + assertDoesNotThrow( + () -> commentService.update(authPrincipal, commentId, request) + ); + assertThat(comment.getImage().getObjectKey()).isEqualTo(newObjectKey); + assertThat(comment.getContent().getValue()).isEqualTo(newContent); + } + + @ParameterizedTest + @DisplayName("댓글 삭제") + @EnumSource(value = CommentFixture.class) + public void delete(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final Long memberId = authPrincipal.id(); + + // when + doNothing().when(commentRepository) + .deleteByIdAndMemberId(commentId, memberId); + + // then + assertDoesNotThrow( + () -> commentService.delete(authPrincipal, commentId) + ); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요") + @EnumSource(value = CommentFixture.class) + public void likeComment(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final Long memberId = authPrincipal.id(); + final CommentLikeEvent event = CommentLikeEvent.of(authPrincipal, commentId); + + // when + doReturn(false).when(commentLikeRepository) + .existsByCommentIdAndMemberId(commentId, memberId); + doNothing().when(eventPublisher) + .publishEvent(event); + + // then + assertDoesNotThrow( + () -> commentService.likeComment(authPrincipal, commentId) + ); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요 취소") + @EnumSource(value = CommentFixture.class) + public void unlikeComment(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final Long memberId = authPrincipal.id(); + final CommentUnlikeEvent event = CommentUnlikeEvent.of(authPrincipal, commentId); + + // when + doReturn(true).when(commentLikeRepository) + .existsByCommentIdAndMemberId(commentId, memberId); + doNothing().when(eventPublisher) + .publishEvent(event); + + // then + assertDoesNotThrow( + () -> commentService.unlikeComment(authPrincipal, commentId) + ); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요시 좋아요를 이미 좋아요를 눌렀다면 예외 발생") + @EnumSource(value = CommentFixture.class) + public void likeCommentWhenAlreadyLiked(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final Long memberId = authPrincipal.id(); + + // when + doReturn(true).when(commentLikeRepository) + .existsByCommentIdAndMemberId(commentId, memberId); + + // then + assertThatThrownBy(() -> commentService.likeComment(authPrincipal, commentId)) + .isInstanceOf(AlreadyLikedException.class); + } + + @ParameterizedTest + @DisplayName("댓글 좋아요 취소시 좋아요를 누른적이 없는 경우 예외 발생") + @EnumSource(value = CommentFixture.class) + public void unlikeCommentWhenNoLike(final CommentFixture fixture) throws Exception { + // given + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + final Long memberId = authPrincipal.id(); + + // when + doReturn(false).when(commentLikeRepository) + .existsByCommentIdAndMemberId(commentId, memberId); + + // then + assertThatThrownBy(() -> commentService.unlikeComment(authPrincipal, commentId)) + .isInstanceOf(NoLikedException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/comment/model/CommentContentTest.java b/src/test/java/ject/componote/domain/comment/model/CommentContentTest.java new file mode 100644 index 00000000..a0712c27 --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/model/CommentContentTest.java @@ -0,0 +1,37 @@ +package ject.componote.domain.comment.model; + +import ject.componote.domain.comment.error.BlankCommentException; +import ject.componote.domain.comment.error.ExceedCommentLengthException; +import ject.componote.domain.comment.error.OffensiveCommentException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CommentContentTest { + @Test + @DisplayName("댓글 내용이 너무 긴 경우 예외 발생") + public void exceedLength() throws Exception { + final String value = "H".repeat(1_500); + assertThatThrownBy(() -> CommentContent.from(value)) + .isInstanceOf(ExceedCommentLengthException.class); + } + + @Test + @DisplayName("댓글 내용이 없는 경우 예외 발생") + public void isNullOrEmpty() throws Exception { + final String value = ""; + assertThatThrownBy(() -> CommentContent.from(value)) + .isInstanceOf(BlankCommentException.class); + } + + @ParameterizedTest + @DisplayName("비속어 필터링") + @ValueSource(strings = {"씨발", "개새끼"}) + public void badWordFiltering(final String value) throws Exception { + assertThatThrownBy(() -> CommentContent.from(value)) + .isInstanceOf(OffensiveCommentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/comment/model/CommentImageTest.java b/src/test/java/ject/componote/domain/comment/model/CommentImageTest.java new file mode 100644 index 00000000..a46bd78a --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/model/CommentImageTest.java @@ -0,0 +1,29 @@ +package ject.componote.domain.comment.model; + +import ject.componote.domain.comment.error.InvalidCommentImageExtensionException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CommentImageTest { + @Test + @DisplayName("이미지 ObjectKey가 없는 경우 null 저장") + public void isNullOrEmpty() throws Exception { + final String objectKey = ""; + final CommentImage commentImage = CommentImage.from(objectKey); + assertThat(commentImage).isNotNull(); + assertThat(commentImage.getObjectKey()).isNull(); + } + + @ParameterizedTest + @DisplayName("이미지 ObjectKey 확장자가 잘못된 경우 예외 발생") + @ValueSource(strings = {"hello.jp", "hello.gf"}) + public void invalidExtension(final String objectKey) throws Exception { + assertThatThrownBy(() -> CommentImage.from(objectKey)) + .isInstanceOf(InvalidCommentImageExtensionException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/comment/validation/CommenterValidationAspectTest.java b/src/test/java/ject/componote/domain/comment/validation/CommenterValidationAspectTest.java new file mode 100644 index 00000000..e2484289 --- /dev/null +++ b/src/test/java/ject/componote/domain/comment/validation/CommenterValidationAspectTest.java @@ -0,0 +1,69 @@ +package ject.componote.domain.comment.validation; + +import ject.componote.domain.auth.domain.Member; +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.comment.dao.CommentRepository; +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.error.NotFoundCommentException; +import ject.componote.fixture.CommentFixture; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static ject.componote.fixture.MemberFixture.KIM; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class CommenterValidationAspectTest { + @Mock + CommentRepository commentRepository; + + @InjectMocks + CommenterValidationAspect commenterValidationAspect; + + final Member member = KIM.생성(1L); + final AuthPrincipal authPrincipal = AuthPrincipal.from(member); + + @ParameterizedTest + @DisplayName("댓글 작성자 검증") + @EnumSource(CommentFixture.class) + public void validate(final CommentFixture fixture) throws Exception { + // given + final Long memberId = member.getId(); + final Comment comment = fixture.생성(memberId); + final Long commentId = comment.getId(); + + // when + doReturn(true).when(commentRepository) + .existsByIdAndMemberId(commentId, memberId); + + // then + assertDoesNotThrow( + () -> commenterValidationAspect.validate(authPrincipal, commentId) + ); + } + + @ParameterizedTest + @DisplayName("댓글 작성자가 아닌 경우 예외 발생") + @EnumSource(CommentFixture.class) + public void validateWhenInvalidMemberId(final CommentFixture fixture) throws Exception { + // given + final Long memberId = member.getId(); + final Comment comment = fixture.생성(); + final Long commentId = comment.getId(); + + // when + doReturn(false).when(commentRepository) + .existsByIdAndMemberId(commentId, memberId); + + // then + assertThatThrownBy(() -> commenterValidationAspect.validate(authPrincipal, commentId)) + .isInstanceOf(NotFoundCommentException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/application/ComponentSearchStrategyTest.java b/src/test/java/ject/componote/domain/component/application/ComponentSearchStrategyTest.java new file mode 100644 index 00000000..04e802d1 --- /dev/null +++ b/src/test/java/ject/componote/domain/component/application/ComponentSearchStrategyTest.java @@ -0,0 +1,133 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.dao.ComponentSummaryDao; +import ject.componote.domain.component.domain.ComponentType; +import ject.componote.domain.component.dto.find.request.ComponentSearchRequest; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.List; +import java.util.stream.Stream; + +import static ject.componote.fixture.MemberFixture.KIM; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ComponentSearchStrategyParameterizedTest { + @Mock + ComponentRepository componentRepository; + + static Stream provideTestInputs() { + final AuthPrincipal authPrincipal = AuthPrincipal.from(KIM.생성(1L)); + final List types = List.of(ComponentType.INPUT, ComponentType.DISPLAY); + return Stream.of( + new TestInput( + "WITH_BOOKMARK_AND_FILTER", + new ComponentSearchRequest("keyword", types), + authPrincipal, + true, + "searchWithBookmarkAndTypes" + ), + new TestInput( + "WITH_BOOKMARK", + new ComponentSearchRequest("keyword", null), + authPrincipal, + true, + "searchWithBookmark" + ), + new TestInput( + "WITHOUT_BOOKMARK_AND_FILTER", + new ComponentSearchRequest("keyword", null), + null, + false, + "searchByKeyword" + ), + new TestInput( + "WITHOUT_BOOKMARK", + new ComponentSearchRequest("keyword", types), + null, + false, + "searchByKeywordWithTypes" + ) + ); + } + + @ParameterizedTest + @MethodSource("provideTestInputs") + @DisplayName("요청값에 알맞는 검색 메서드 실행") + void testComponentSearchStrategy(final TestInput input) { + // given + final AuthPrincipal authPrincipal = input.authPrincipal; + final String keyword = input.request.keyword(); + final List types = input.request.types(); + final Pageable pageable = Pageable.unpaged(); + final Page expect = new PageImpl<>(List.of()); + + // when + switch (input.expectedMethod) { + case "searchWithBookmarkAndTypes" -> doReturn(expect).when(componentRepository) + .searchWithBookmarkAndTypes(authPrincipal.id(), keyword, types, pageable); + case "searchWithBookmark" -> doReturn(expect).when(componentRepository) + .searchWithBookmark(authPrincipal.id(), keyword, pageable); + case "searchByKeywordWithTypes" -> doReturn(expect).when(componentRepository) + .searchByKeywordWithTypes(keyword, types, pageable); + case "searchByKeyword" -> doReturn(expect).when(componentRepository) + .searchByKeyword(keyword, pageable); + default -> throw new IllegalStateException("Unexpected value: " + input.expectedMethod); + } + + final Page actual = ComponentSearchStrategy.searchBy( + authPrincipal, + componentRepository, + input.request, + pageable + ); + + // then + assertNotNull(actual); + + // 메서드 호출 여부 검증 + switch (input.expectedMethod) { + case "searchWithBookmarkAndTypes" -> verify(componentRepository) + .searchWithBookmarkAndTypes(authPrincipal.id(), keyword, types, pageable); + case "searchWithBookmark" -> verify(componentRepository) + .searchWithBookmark(authPrincipal.id(), keyword, pageable); + case "searchByKeywordWithTypes" -> verify(componentRepository) + .searchByKeywordWithTypes(keyword, types, pageable); + case "searchByKeyword" -> verify(componentRepository) + .searchByKeyword(keyword, pageable); + default -> throw new IllegalStateException("Unexpected value: " + input.expectedMethod); + } + } + + static class TestInput { + String strategyName; + ComponentSearchRequest request; + AuthPrincipal authPrincipal; + boolean isLoggedIn; + String expectedMethod; + + TestInput(final String strategyName, + final ComponentSearchRequest request, + final AuthPrincipal authPrincipal, + final boolean isLoggedIn, + final String expectedMethod) { + this.strategyName = strategyName; + this.request = request; + this.authPrincipal = authPrincipal; + this.isLoggedIn = isLoggedIn; + this.expectedMethod = expectedMethod; + } + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/application/ComponentServiceTest.java b/src/test/java/ject/componote/domain/component/application/ComponentServiceTest.java new file mode 100644 index 00000000..b78f5356 --- /dev/null +++ b/src/test/java/ject/componote/domain/component/application/ComponentServiceTest.java @@ -0,0 +1,210 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.auth.model.AuthPrincipal; +import ject.componote.domain.bookmark.dao.BookmarkRepository; +import ject.componote.domain.common.dto.response.PageResponse; +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.dao.ComponentSummaryDao; +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.domain.ComponentType; +import ject.componote.domain.component.dto.find.event.ComponentViewCountIncreaseEvent; +import ject.componote.domain.component.dto.find.request.ComponentSearchRequest; +import ject.componote.domain.component.dto.find.response.ComponentDetailResponse; +import ject.componote.domain.component.dto.find.response.ComponentSummaryResponse; +import ject.componote.domain.component.error.NotFoundComponentException; +import ject.componote.domain.component.util.ComponentMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static ject.componote.fixture.ComponentFixture.INPUT_COMPONENT; +import static ject.componote.fixture.MemberFixture.KIM; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ComponentServiceTest { + @Mock + ApplicationEventPublisher eventPublisher; + + @Mock + BookmarkRepository bookmarkRepository; + + @Mock + ComponentMapper componentMapper; + + @Mock + ComponentRepository componentRepository; + + @InjectMocks + ComponentService componentService; + + Component component = INPUT_COMPONENT.생성(); + + static Stream provideDetailInputs() { + final AuthPrincipal authPrincipal = AuthPrincipal.from(KIM.생성(1L)); + return Stream.of( + new DetailInput("비회원", null, false), + new DetailInput("회원 (북마크 X)", authPrincipal, false), + new DetailInput("회원 (북마크 O)", authPrincipal, true) + ); + } + + static Stream provideSearchInputs() { + final AuthPrincipal authPrincipal = AuthPrincipal.from(KIM.생성(1L)); + return Stream.of( + new SearchInput("비회원, 필터 X", null, "검색어", null), + new SearchInput("비회원, 필터 O", null, "검색어", List.of(ComponentType.INPUT)), + new SearchInput("회원, 필터 X", authPrincipal, "검색어", null), + new SearchInput("회원, 필터 O", authPrincipal, "검색어", List.of(ComponentType.INPUT)) + ); + } + + @ParameterizedTest + @MethodSource("provideDetailInputs") + @DisplayName("컴포넌트 상세 조회") + public void getComponentDetail(final DetailInput input) throws Exception { + // given + final Long componentId = component.getId(); + final ComponentViewCountIncreaseEvent event = ComponentViewCountIncreaseEvent.from(component); + final ComponentDetailResponse expect = new ComponentDetailResponse( + component.getSummary().getTitle(), + Collections.emptyList(), + component.getSummary().getDescription(), + component.getCommentCount().getValue(), + component.getBookmarkCount().getValue(), + component.getDesignReferenceCount().getValue(), + component.getSummary().getThumbnail().toUrl(), + Collections.emptyMap(), + input.isBookmarked + ); + + // when + doReturn(Optional.of(component)).when(componentRepository) + .findById(componentId); + doNothing().when(eventPublisher) + .publishEvent(event); + doReturn(expect).when(componentMapper) + .mapFrom(component, input.isBookmarked); + if (input.isBookmarked) { + doReturn(true).when(bookmarkRepository) + .existsByComponentIdAndMemberId(componentId, input.authPrincipal.id()); + } + + final ComponentDetailResponse actual = componentService.getComponentDetail(input.authPrincipal, componentId); + + // then + assertThat(actual).isEqualTo(expect); + } + + @ParameterizedTest + @MethodSource("provideDetailInputs") + @DisplayName("컴포넌트 상세 조회 시 componentId 가 잘못된 경우 예외 발생") + public void getComponentDetailWhenInvalidComponentId(final DetailInput input) throws Exception { + // given + final Long componentId = component.getId(); + + // when + doReturn(Optional.empty()).when(componentRepository) + .findById(componentId); + + // then + assertThatThrownBy(() -> componentService.getComponentDetail(input.authPrincipal, componentId)) + .isInstanceOf(NotFoundComponentException.class); + } + + @ParameterizedTest + @MethodSource("provideSearchInputs") + @DisplayName("컴포넌트 검색") + public void search(final SearchInput input) throws Exception { + // given + final AuthPrincipal authPrincipal = input.authPrincipal; + final String keyword = input.keyword; + final List types = input.types; + final ComponentSearchRequest request = input.toRequest(); + final Pageable pageable = input.pageable; + final Page page = new PageImpl<>(Collections.emptyList(), pageable, 0L); + + // when + switch (input.displayName) { + case "비회원, 필터 X" -> doReturn(page).when(componentRepository) + .searchByKeyword(keyword, pageable); + case "비회원, 필터 O" -> doReturn(page).when(componentRepository) + .searchByKeywordWithTypes(keyword, types, pageable); + case "회원, 필터 X" -> doReturn(page).when(componentRepository) + .searchWithBookmark(authPrincipal.id(), keyword, pageable); + case "회원, 필터 O" -> doReturn(page).when(componentRepository) + .searchWithBookmarkAndTypes(authPrincipal.id(), keyword, types, pageable); + default -> throw new IllegalStateException("Unexpected value: " + input.displayName); + } + + final PageResponse actual = componentService.search(authPrincipal, request, pageable); + + // then + assertThat(actual).isNotNull(); + switch (input.displayName) { + case "비회원, 필터 X" -> verify(componentRepository) + .searchByKeyword(keyword, pageable); + case "비회원, 필터 O" -> verify(componentRepository) + .searchByKeywordWithTypes(keyword, types, pageable); + case "회원, 필터 X" -> verify(componentRepository) + .searchWithBookmark(authPrincipal.id(), keyword, pageable); + case "회원, 필터 O" -> verify(componentRepository) + .searchWithBookmarkAndTypes(authPrincipal.id(), keyword, types, pageable); + default -> throw new IllegalStateException("Unexpected value: " + input.displayName); + } + } + + static class DetailInput { + String displayName; + AuthPrincipal authPrincipal; + boolean isBookmarked; + + public DetailInput(final String displayName, + final AuthPrincipal authPrincipal, + final boolean isBookmarked) { + this.displayName = displayName; + this.authPrincipal = authPrincipal; + this.isBookmarked = isBookmarked; + } + } + + static class SearchInput { + String displayName; + AuthPrincipal authPrincipal; + String keyword; + List types; + Pageable pageable; + + public SearchInput(final String displayName, + final AuthPrincipal authPrincipal, + final String keyword, + final List types) { + this.displayName = displayName; + this.authPrincipal = authPrincipal; + this.keyword = keyword; + this.types = types; + this.pageable = Pageable.unpaged(); + } + + public ComponentSearchRequest toRequest() { + return new ComponentSearchRequest(keyword, types); + } + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/application/ComponentViewCountEventHandlerTest.java b/src/test/java/ject/componote/domain/component/application/ComponentViewCountEventHandlerTest.java new file mode 100644 index 00000000..861789d0 --- /dev/null +++ b/src/test/java/ject/componote/domain/component/application/ComponentViewCountEventHandlerTest.java @@ -0,0 +1,67 @@ +package ject.componote.domain.component.application; + +import ject.componote.domain.common.model.Count; +import ject.componote.domain.component.dao.ComponentRepository; +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.dto.find.event.ComponentViewCountIncreaseEvent; +import ject.componote.domain.component.error.NotFoundComponentException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static ject.componote.fixture.ComponentFixture.INPUT_COMPONENT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; + +@ExtendWith(MockitoExtension.class) +class ComponentViewCountEventHandlerTest { + @Mock + ComponentRepository componentRepository; + + @InjectMocks + ComponentViewCountEventHandler componentViewCountEventHandler; + + Component component = INPUT_COMPONENT.생성(); + + @Test + @DisplayName("조회 수 증가") + public void handleViewCountIncrease() throws Exception { + // given + final ComponentViewCountIncreaseEvent event = ComponentViewCountIncreaseEvent.from(component); + final Long componentId = component.getId(); + final Count previousViewCount = component.getViewCount(); + + // when + doReturn(Optional.of(component)).when(componentRepository) + .findById(componentId); + componentViewCountEventHandler.handleViewCountIncrease(event); + + // then + final Count newViewCount = component.getViewCount(); + previousViewCount.increase(); + assertThat(previousViewCount).isEqualTo(newViewCount); + } + + @Test + @DisplayName("조회 수 증가 시 componentId 가 잘못된 경우 예외 발생") + public void handleViewCountIncreaseWhenInvalidComponentId() throws Exception { + // given + final ComponentViewCountIncreaseEvent event = ComponentViewCountIncreaseEvent.from(component); + final Long componentId = component.getId(); + + // when + doReturn(Optional.empty()).when(componentRepository) + .findById(componentId); + + // then + assertThatThrownBy(() -> componentViewCountEventHandler.handleViewCountIncrease(event)) + .isInstanceOf(NotFoundComponentException.class); + + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/model/ComponentImageTest.java b/src/test/java/ject/componote/domain/component/model/ComponentImageTest.java new file mode 100644 index 00000000..67aafef3 --- /dev/null +++ b/src/test/java/ject/componote/domain/component/model/ComponentImageTest.java @@ -0,0 +1,31 @@ +package ject.componote.domain.component.model; + +import ject.componote.domain.component.error.InvalidComponentImageExtensionException; +import ject.componote.domain.component.error.NotFoundComponentImageException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ComponentImageTest { + @ParameterizedTest(name = "값: {0}") + @DisplayName("확장자가 잘못된 경우") + @ValueSource(strings = {"hello.gif", "hello", "hello.jqp"}) + public void invalidExtension(final String objectKey) { + assertThatThrownBy(() -> ComponentImage.from(objectKey)) + .isInstanceOf(InvalidComponentImageExtensionException.class); + } + + @Test + @DisplayName("objectKey가 전달되지 않으면 예외 발생") + public void createDefault() { + // given + final String objectKey = null; + + // then + assertThatThrownBy(() -> ComponentImage.from(objectKey)) + .isInstanceOf(NotFoundComponentImageException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/model/ComponentThumbnailTest.java b/src/test/java/ject/componote/domain/component/model/ComponentThumbnailTest.java new file mode 100644 index 00000000..765157d7 --- /dev/null +++ b/src/test/java/ject/componote/domain/component/model/ComponentThumbnailTest.java @@ -0,0 +1,31 @@ +package ject.componote.domain.component.model; + +import ject.componote.domain.component.error.InvalidComponentThumbnailExtensionException; +import ject.componote.domain.component.error.NotFoundComponentThumbnailException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ComponentThumbnailTest { + @ParameterizedTest(name = "값: {0}") + @DisplayName("확장자가 잘못된 경우") + @ValueSource(strings = {"hello.gif", "hello", "hello.jqp"}) + public void invalidExtension(final String objectKey) { + assertThatThrownBy(() -> ComponentThumbnail.from(objectKey)) + .isInstanceOf(InvalidComponentThumbnailExtensionException.class); + } + + @Test + @DisplayName("objectKey가 전달되지 않으면 예외 발생") + public void createDefault() { + // given + final String objectKey = null; + + // then + assertThatThrownBy(() -> ComponentThumbnail.from(objectKey)) + .isInstanceOf(NotFoundComponentThumbnailException.class); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/domain/component/util/ComponentMapperTest.java b/src/test/java/ject/componote/domain/component/util/ComponentMapperTest.java new file mode 100644 index 00000000..8b8c279b --- /dev/null +++ b/src/test/java/ject/componote/domain/component/util/ComponentMapperTest.java @@ -0,0 +1,39 @@ +package ject.componote.domain.component.util; + +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.dto.find.response.ComponentDetailResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static ject.componote.fixture.ComponentFixture.INPUT_COMPONENT; +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +class ComponentMapperTest { + @InjectMocks + ComponentMapper componentMapper; + + @ParameterizedTest + @DisplayName("Component를 ComponentDetailResponse로 변환") + @ValueSource(booleans = {true, false}) + public void mapFromWithBookmark(final boolean isBookmarked) throws Exception { + // given + final Component component = INPUT_COMPONENT.생성(); + + // when + final ComponentDetailResponse response = componentMapper.mapFrom(component, isBookmarked); + + // then + assertThat(response).isNotNull(); + assertThat(response.isBookmarked()).isEqualTo(isBookmarked); + assertThat(response.bookmarkCount()).isEqualTo(component.getBookmarkCount().getValue()); + assertThat(response.commentCount()).isEqualTo(component.getCommentCount().getValue()); + assertThat(response.title()).isEqualTo(component.getSummary().getTitle()); + assertThat(response.description()).isEqualTo(component.getSummary().getDescription()); + assertThat(response.thumbnailUrl()).isEqualTo(component.getSummary().getThumbnail().toUrl()); + } +} \ No newline at end of file diff --git a/src/test/java/ject/componote/fixture/CommentFixture.java b/src/test/java/ject/componote/fixture/CommentFixture.java new file mode 100644 index 00000000..ea088810 --- /dev/null +++ b/src/test/java/ject/componote/fixture/CommentFixture.java @@ -0,0 +1,38 @@ +package ject.componote.fixture; + +import ject.componote.domain.comment.application.CommentCreationStrategy; +import ject.componote.domain.comment.domain.Comment; +import ject.componote.domain.comment.dto.create.request.CommentCreateRequest; + +public enum CommentFixture { + 댓글_이미지X(1L, 1L, null, "일반 댓글입니다.", null), + 댓글_이미지O(1L, 1L, null, "일반 댓글입니다.", "comments/image1.jpg"), + 답글_이미지X(1L, 1L, 100L, "답글입니다.", null), + 답글_이미지O(1L, 1L, 100L, "답글입니다.", "comments/image2.jpg"); + + private final Long componentId; + private final Long memberId; + private final Long parentId; + private final String content; + private final String imageObjectKey; + + CommentFixture(Long componentId, Long memberId, Long parentId, String content, String imageObjectKey) { + this.componentId = componentId; + this.memberId = memberId; + this.parentId = parentId; + this.content = content; + this.imageObjectKey = imageObjectKey; + } + + public Comment 생성(final Long memberId) { + return CommentCreationStrategy.createBy(toCreateRequest(), memberId); + } + + public Comment 생성() { + return CommentCreationStrategy.createBy(toCreateRequest(), memberId); + } + + public CommentCreateRequest toCreateRequest() { + return new CommentCreateRequest(imageObjectKey, content, componentId, parentId); + } +} diff --git a/src/test/java/ject/componote/fixture/ComponentFixture.java b/src/test/java/ject/componote/fixture/ComponentFixture.java index 62fd8fe8..265a812b 100644 --- a/src/test/java/ject/componote/fixture/ComponentFixture.java +++ b/src/test/java/ject/componote/fixture/ComponentFixture.java @@ -62,3 +62,31 @@ public String getSummaryText() { } } +======= +import ject.componote.domain.component.domain.Component; +import ject.componote.domain.component.domain.ComponentType; + +import java.util.Collections; +import java.util.List; + +public enum ComponentFixture { + INPUT_COMPONENT("input title", "input description", "objectKey.jpg", ComponentType.INPUT, List.of("hello")); + + private final String title; + private final String description; + private final String thumbnailObjectKey; + private final ComponentType type; + private final List mixedNames; + + ComponentFixture(final String title, final String description, final String thumbnailObjectKey, final ComponentType type, final List mixedNames) { + this.title = title; + this.description = description; + this.thumbnailObjectKey = thumbnailObjectKey; + this.type = type; + this.mixedNames = mixedNames; + } + + public Component 생성() { + return Component.of(title, description, thumbnailObjectKey, type, mixedNames, Collections.emptyList()); + } +}