From 5d2e717eba11319c699a923e95e10d8d64fdb12e Mon Sep 17 00:00:00 2001 From: Hanuel Lee <91522259+hanueleee@users.noreply.github.com> Date: Mon, 16 Oct 2023 13:34:50 +0900 Subject: [PATCH] =?UTF-8?q?[BE]=20feat:=20=EB=A6=AC=EB=B7=B0=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#735)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: 해당 review에 달린 tag를 삭제하는 기능 추가 * feat: 해당 review에 달린 favorite을 삭제하는 기능 추가 * feat: NotAuthorOfReviewException 추가 * feat: 리뷰 삭제 기능 구현 * feat: s3 이미지 삭제 기능 구현 * test: 리뷰 삭제 기능에 대한 인수테스트 작성 * refactor: 리뷰 반영 * refactor: deleteAllByIdInBatch적용 * test: 리뷰 삭제 실패 케이스 추가 * refactor: updateProductImage 메서드 중복 제거 * feat: s3 파일 경로 지정 로직 추가 * refactor: 리뷰에 이미지가 존재할 때에만 s3 delete 로직 실행하도록 수정 * refactor: 리뷰 삭제 성공시 상태코드 204 반환 * test: 리뷰 삭제 성공시 상태코드 204 반환하도록 인수테스트 수정 * feat: s3 이미지 삭제 로직 이벤트 처리 * refactor: 이미지 있을 때만 이벤트 발행하던 로직을 이미지 유무 상관없이 이벤트 발행하도록 수정 (이미지 유무 처리를 이벤트리스너에서 하도록) * test: 리뷰 삭제 이벤트 관련 테스트 추가 * test: 리뷰 삭제 이벤트 관련 테스트 보완 * refactor: ReviewTagRepositoryTest의 deleteByReview 테스트 간소화 * feat: application.yml에 스레드 풀 설정 추가 * refactor: member를 equals로 비교하도록 수정 * chore: 컨벤션 적용 * refactor: 세션 이름 복구 * refactor: 리뷰 반영 * refactor: reviewId 대신 review로 delete하도록 수정 * refactor: s3 이미지 삭제 실패 로그 문구 수정 * refactor: 리뷰 삭제시 deleteById 대신 delete로 수정 * feat: 리뷰 삭제 api 수정 사항 적용 * style: EventTest 메소드 줄바꿈 --- .../java/com/funeat/FuneatApplication.java | 3 +- .../java/com/funeat/common/ImageUploader.java | 2 + .../common/exception/CommonException.java | 6 + .../java/com/funeat/common/s3/S3Uploader.java | 19 ++ .../persistence/ReviewFavoriteRepository.java | 5 + .../presentation/MemberApiController.java | 11 + .../member/presentation/MemberController.java | 11 + .../review/application/ReviewDeleteEvent.java | 14 ++ .../ReviewDeleteEventListener.java | 27 +++ .../review/application/ReviewService.java | 57 ++++- .../java/com/funeat/review/domain/Review.java | 4 + .../review/exception/ReviewErrorCode.java | 1 + .../review/exception/ReviewException.java | 6 + .../persistence/ReviewTagRepository.java | 5 + .../presentation/ReviewApiController.java | 6 +- .../review/presentation/ReviewController.java | 4 +- backend/src/main/resources/application.yml | 5 + .../member/MemberAcceptanceTest.java | 82 +++++++ .../funeat/acceptance/member/MemberSteps.java | 9 + .../java/com/funeat/common/EventTest.java | 66 ++++++ .../com/funeat/common/TestImageUploader.java | 4 + .../ReviewFavoriteRepositoryTest.java | 75 ++++++ .../ReviewDeleteEventListenerTest.java | 217 ++++++++++++++++++ .../review/application/ReviewServiceTest.java | 169 ++++++++++++-- .../persistence/ReviewTagRepositoryTest.java | 74 ++++++ 25 files changed, 858 insertions(+), 24 deletions(-) create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java create mode 100644 backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java create mode 100644 backend/src/test/java/com/funeat/common/EventTest.java create mode 100644 backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java diff --git a/backend/src/main/java/com/funeat/FuneatApplication.java b/backend/src/main/java/com/funeat/FuneatApplication.java index 53bd185c0..34909202c 100644 --- a/backend/src/main/java/com/funeat/FuneatApplication.java +++ b/backend/src/main/java/com/funeat/FuneatApplication.java @@ -3,8 +3,10 @@ import com.funeat.common.repository.BaseRepositoryImpl; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +@EnableAsync @SpringBootApplication @EnableJpaRepositories(repositoryBaseClass = BaseRepositoryImpl.class) public class FuneatApplication { @@ -12,5 +14,4 @@ public class FuneatApplication { public static void main(String[] args) { SpringApplication.run(FuneatApplication.class, args); } - } diff --git a/backend/src/main/java/com/funeat/common/ImageUploader.java b/backend/src/main/java/com/funeat/common/ImageUploader.java index 754b1affd..afd4b5c10 100644 --- a/backend/src/main/java/com/funeat/common/ImageUploader.java +++ b/backend/src/main/java/com/funeat/common/ImageUploader.java @@ -5,4 +5,6 @@ public interface ImageUploader { String upload(final MultipartFile image); + + void delete(final String fileName); } diff --git a/backend/src/main/java/com/funeat/common/exception/CommonException.java b/backend/src/main/java/com/funeat/common/exception/CommonException.java index e2e822c68..55be12d5d 100644 --- a/backend/src/main/java/com/funeat/common/exception/CommonException.java +++ b/backend/src/main/java/com/funeat/common/exception/CommonException.java @@ -22,4 +22,10 @@ public S3UploadFailException(final CommonErrorCode errorCode) { super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); } } + + public static class S3DeleteFailException extends CommonException { + public S3DeleteFailException(final CommonErrorCode errorCode) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage())); + } + } } diff --git a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java index 3f9c86caa..97e6241b7 100644 --- a/backend/src/main/java/com/funeat/common/s3/S3Uploader.java +++ b/backend/src/main/java/com/funeat/common/s3/S3Uploader.java @@ -3,15 +3,19 @@ import static com.funeat.exception.CommonErrorCode.IMAGE_EXTENSION_ERROR_CODE; import static com.funeat.exception.CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE; +import com.amazonaws.AmazonServiceException; import com.amazonaws.services.s3.AmazonS3; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; import com.funeat.common.ImageUploader; import com.funeat.common.exception.CommonException.NotAllowedFileExtensionException; +import com.funeat.common.exception.CommonException.S3DeleteFailException; import com.funeat.common.exception.CommonException.S3UploadFailException; import java.io.IOException; import java.util.List; import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -21,8 +25,11 @@ @Profile("!test") public class S3Uploader implements ImageUploader { + private static final int BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH = 31; private static final List INCLUDE_EXTENSIONS = List.of("image/jpeg", "image/png", "image/webp"); + private final Logger log = LoggerFactory.getLogger(this.getClass()); + @Value("${cloud.aws.s3.bucket}") private String bucket; @@ -53,6 +60,18 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String image) { + final String imageName = image.substring(BEGIN_FILE_NAME_INDEX_WITHOUT_CLOUDFRONT_PATH); + try { + final String key = folder + imageName; + amazonS3.deleteObject(bucket, key); + } catch (final AmazonServiceException e) { + log.error("S3 이미지 삭제에 실패했습니다. 이미지 경로 : {}", image); + throw new S3DeleteFailException(UNKNOWN_SERVER_ERROR_CODE); + } + } + private void validateExtension(final MultipartFile image) { final String contentType = image.getContentType(); if (!INCLUDE_EXTENSIONS.contains(contentType)) { diff --git a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java index 2e96e623a..f1ae40e5d 100644 --- a/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java +++ b/backend/src/main/java/com/funeat/member/persistence/ReviewFavoriteRepository.java @@ -3,10 +3,15 @@ import com.funeat.member.domain.Member; import com.funeat.member.domain.favorite.ReviewFavorite; import com.funeat.review.domain.Review; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewFavoriteRepository extends JpaRepository { Optional findByMemberAndReview(final Member member, final Review review); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java index 6ee963d54..af00932f7 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberApiController.java @@ -15,7 +15,9 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; 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.PutMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; @@ -69,4 +71,13 @@ public ResponseEntity getMemberRecipe(@AuthenticationPrin return ResponseEntity.ok().body(response); } + + @Logging + @DeleteMapping("/reviews/{reviewId}") + public ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo) { + reviewService.deleteReview(reviewId, loginInfo.getId()); + + return ResponseEntity.noContent().build(); + } } diff --git a/backend/src/main/java/com/funeat/member/presentation/MemberController.java b/backend/src/main/java/com/funeat/member/presentation/MemberController.java index 5d5748fd7..9c5e60763 100644 --- a/backend/src/main/java/com/funeat/member/presentation/MemberController.java +++ b/backend/src/main/java/com/funeat/member/presentation/MemberController.java @@ -12,7 +12,9 @@ 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.PutMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; @@ -55,4 +57,13 @@ ResponseEntity getMemberReview(@AuthenticationPrincipal f @GetMapping ResponseEntity getMemberRecipe(@AuthenticationPrincipal final LoginInfo loginInfo, @PageableDefault final Pageable pageable); + + @Operation(summary = "리뷰 삭제", description = "자신이 작성한 리뷰를 삭제한다.") + @ApiResponse( + responseCode = "204", + description = "리뷰 삭제 성공." + ) + @DeleteMapping + ResponseEntity deleteReview(@PathVariable final Long reviewId, + @AuthenticationPrincipal final LoginInfo loginInfo); } diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java new file mode 100644 index 000000000..7c69eee3c --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEvent.java @@ -0,0 +1,14 @@ +package com.funeat.review.application; + +public class ReviewDeleteEvent { + + private final String image; + + public ReviewDeleteEvent(final String image) { + this.image = image; + } + + public String getImage() { + return image; + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java new file mode 100644 index 000000000..2009e3936 --- /dev/null +++ b/backend/src/main/java/com/funeat/review/application/ReviewDeleteEventListener.java @@ -0,0 +1,27 @@ +package com.funeat.review.application; + +import com.funeat.common.ImageUploader; +import io.micrometer.core.instrument.util.StringUtils; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +public class ReviewDeleteEventListener { + + private final ImageUploader imageUploader; + + public ReviewDeleteEventListener(final ImageUploader imageUploader) { + this.imageUploader = imageUploader; + } + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void deleteReviewImageInS3(final ReviewDeleteEvent event) { + final String image = event.getImage(); + if (StringUtils.isBlank(image)) { + imageUploader.delete(image); + } + } +} diff --git a/backend/src/main/java/com/funeat/review/application/ReviewService.java b/backend/src/main/java/com/funeat/review/application/ReviewService.java index ee482a8c5..027f8d5ae 100644 --- a/backend/src/main/java/com/funeat/review/application/ReviewService.java +++ b/backend/src/main/java/com/funeat/review/application/ReviewService.java @@ -3,6 +3,7 @@ import static com.funeat.member.exception.MemberErrorCode.MEMBER_DUPLICATE_FAVORITE; import static com.funeat.member.exception.MemberErrorCode.MEMBER_NOT_FOUND; import static com.funeat.product.exception.ProductErrorCode.PRODUCT_NOT_FOUND; +import static com.funeat.review.exception.ReviewErrorCode.NOT_AUTHOR_OF_REVIEW; import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import com.funeat.common.ImageUploader; @@ -27,6 +28,7 @@ import com.funeat.review.dto.ReviewFavoriteRequest; import com.funeat.review.dto.SortingReviewDto; import com.funeat.review.dto.SortingReviewsResponse; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.review.persistence.ReviewRepository; import com.funeat.review.persistence.ReviewTagRepository; @@ -35,6 +37,7 @@ import java.util.List; import java.util.Optional; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -58,11 +61,13 @@ public class ReviewService { private final ProductRepository productRepository; private final ReviewFavoriteRepository reviewFavoriteRepository; private final ImageUploader imageUploader; + private final ApplicationEventPublisher eventPublisher; public ReviewService(final ReviewRepository reviewRepository, final TagRepository tagRepository, final ReviewTagRepository reviewTagRepository, final MemberRepository memberRepository, final ProductRepository productRepository, - final ReviewFavoriteRepository reviewFavoriteRepository, final ImageUploader imageUploader) { + final ReviewFavoriteRepository reviewFavoriteRepository, + final ImageUploader imageUploader, final ApplicationEventPublisher eventPublisher) { this.reviewRepository = reviewRepository; this.tagRepository = tagRepository; this.reviewTagRepository = reviewTagRepository; @@ -70,6 +75,7 @@ public ReviewService(final ReviewRepository reviewRepository, final TagRepositor this.productRepository = productRepository; this.reviewFavoriteRepository = reviewFavoriteRepository; this.imageUploader = imageUploader; + this.eventPublisher = eventPublisher; } @Transactional @@ -124,14 +130,11 @@ private ReviewFavorite saveReviewFavorite(final Member member, final Review revi } @Transactional - public void updateProductImage(final Long reviewId) { - final Review review = reviewRepository.findById(reviewId) - .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + public void updateProductImage(final Long productId) { + final Product product = productRepository.findById(productId) + .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); - final Product product = review.getProduct(); - final Long productId = product.getId(); final PageRequest pageRequest = PageRequest.of(TOP, ONE); - final List topFavoriteReview = reviewRepository.findPopularReviewWithImage(productId, pageRequest); if (!topFavoriteReview.isEmpty()) { final String topFavoriteReviewImage = topFavoriteReview.get(TOP).getImage(); @@ -180,6 +183,46 @@ public MemberReviewsResponse findReviewByMember(final Long memberId, final Pagea return MemberReviewsResponse.toResponse(pageDto, dtos); } + @Transactional + public void deleteReview(final Long reviewId, final Long memberId) { + final Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberNotFoundException(MEMBER_NOT_FOUND, memberId)); + final Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(REVIEW_NOT_FOUND, reviewId)); + final Product product = review.getProduct(); + final String image = review.getImage(); + + if (review.checkAuthor(member)) { + eventPublisher.publishEvent(new ReviewDeleteEvent(image)); + deleteThingsRelatedToReview(review); + updateProductImage(product.getId()); + return; + } + throw new NotAuthorOfReviewException(NOT_AUTHOR_OF_REVIEW, memberId); + } + + private void deleteThingsRelatedToReview(final Review review) { + deleteReviewTags(review); + deleteReviewFavorites(review); + reviewRepository.delete(review); + } + + private void deleteReviewTags(final Review review) { + final List reviewTags = reviewTagRepository.findByReview(review); + final List ids = reviewTags.stream() + .map(ReviewTag::getId) + .collect(Collectors.toList()); + reviewTagRepository.deleteAllByIdInBatch(ids); + } + + private void deleteReviewFavorites(final Review review) { + final List reviewFavorites = reviewFavoriteRepository.findByReview(review); + final List ids = reviewFavorites.stream() + .map(ReviewFavorite::getId) + .collect(Collectors.toList()); + reviewFavoriteRepository.deleteAllByIdInBatch(ids); + } + public Optional getMostFavoriteReview(final Long productId) { final Product findProduct = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException(PRODUCT_NOT_FOUND, productId)); diff --git a/backend/src/main/java/com/funeat/review/domain/Review.java b/backend/src/main/java/com/funeat/review/domain/Review.java index 3545371e3..d990666d3 100644 --- a/backend/src/main/java/com/funeat/review/domain/Review.java +++ b/backend/src/main/java/com/funeat/review/domain/Review.java @@ -88,6 +88,10 @@ public void minusFavoriteCount() { this.favoriteCount--; } + public boolean checkAuthor(final Member member) { + return Objects.equals(this.member, member); + } + public Long getId() { return id; } diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java index d91c0c8c3..05331dac9 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewErrorCode.java @@ -5,6 +5,7 @@ public enum ReviewErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 리뷰입니다. 리뷰 id를 확인하세요.", "3001"), + NOT_AUTHOR_OF_REVIEW(HttpStatus.BAD_REQUEST, "해당 리뷰를 작성한 회원이 아닙니다", "3002") ; private final HttpStatus status; diff --git a/backend/src/main/java/com/funeat/review/exception/ReviewException.java b/backend/src/main/java/com/funeat/review/exception/ReviewException.java index 4699f3af6..a961f3301 100644 --- a/backend/src/main/java/com/funeat/review/exception/ReviewException.java +++ b/backend/src/main/java/com/funeat/review/exception/ReviewException.java @@ -15,4 +15,10 @@ public ReviewNotFoundException(final ReviewErrorCode errorCode, final Long revie super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), reviewId)); } } + + public static class NotAuthorOfReviewException extends ReviewException { + public NotAuthorOfReviewException(final ReviewErrorCode errorCode, final Long memberId) { + super(errorCode.getStatus(), new ErrorCode<>(errorCode.getCode(), errorCode.getMessage(), memberId)); + } + } } diff --git a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java index 7129a711c..cbdf3c3bf 100644 --- a/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java +++ b/backend/src/main/java/com/funeat/review/persistence/ReviewTagRepository.java @@ -1,5 +1,6 @@ package com.funeat.review.persistence; +import com.funeat.review.domain.Review; import com.funeat.review.domain.ReviewTag; import com.funeat.tag.domain.Tag; import java.util.List; @@ -16,4 +17,8 @@ public interface ReviewTagRepository extends JpaRepository { + "GROUP BY rt.tag " + "ORDER BY cnt DESC") List findTop3TagsByReviewIn(final Long productId, final Pageable pageable); + + void deleteByReview(final Review review); + + List findByReview(final Review review); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java index 57bf20359..ba094cb14 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewApiController.java @@ -17,6 +17,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -49,11 +50,12 @@ public ResponseEntity writeReview(@PathVariable final Long productId, @Logging @PatchMapping("/api/products/{productId}/reviews/{reviewId}") - public ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + public ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody @Valid final ReviewFavoriteRequest request) { reviewService.likeReview(reviewId, loginInfo.getId(), request); - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(productId); return ResponseEntity.noContent().build(); } diff --git a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java index 886ee5a15..2e3d52459 100644 --- a/backend/src/main/java/com/funeat/review/presentation/ReviewController.java +++ b/backend/src/main/java/com/funeat/review/presentation/ReviewController.java @@ -14,6 +14,7 @@ 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.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -42,7 +43,8 @@ ResponseEntity writeReview(@PathVariable final Long productId, description = "리뷰 좋아요(취소) 성공." ) @PatchMapping - ResponseEntity toggleLikeReview(@PathVariable final Long reviewId, + ResponseEntity toggleLikeReview(@PathVariable final Long productId, + @PathVariable final Long reviewId, @AuthenticationPrincipal final LoginInfo loginInfo, @RequestBody final ReviewFavoriteRequest request); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 8235adae4..b15ff14fe 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -6,6 +6,11 @@ spring: enabled: true maxFileSize: 10MB maxRequestSize: 15MB + task: + execution: + pool: + core-size: { THREAD_CORE_SIZE } + max-size: { THREAD_MAX_SIZE } session: store-type: jdbc jdbc: diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java index cb3b8e629..b2a94b2c0 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberAcceptanceTest.java @@ -8,7 +8,9 @@ import static com.funeat.acceptance.common.CommonSteps.잘못된_요청; import static com.funeat.acceptance.common.CommonSteps.정상_처리; import static com.funeat.acceptance.common.CommonSteps.정상_처리_NO_CONTENT; +import static com.funeat.acceptance.common.CommonSteps.찾을수_없음; import static com.funeat.acceptance.common.CommonSteps.페이지를_검증한다; +import static com.funeat.acceptance.member.MemberSteps.리뷰_삭제_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_꿀조합_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_리뷰_조회_요청; import static com.funeat.acceptance.member.MemberSteps.사용자_정보_수정_요청; @@ -33,17 +35,22 @@ import static com.funeat.fixture.PageFixture.총_데이터_개수; import static com.funeat.fixture.PageFixture.총_페이지; import static com.funeat.fixture.PageFixture.최신순; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점5점_생성; import static com.funeat.fixture.RecipeFixture.레시피; import static com.funeat.fixture.RecipeFixture.레시피1; import static com.funeat.fixture.RecipeFixture.레시피2; import static com.funeat.fixture.RecipeFixture.레시피추가요청_생성; +import static com.funeat.fixture.ReviewFixture.리뷰1; +import static com.funeat.fixture.ReviewFixture.리뷰2; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매X_생성; import static com.funeat.fixture.ScoreFixture.점수_1점; import static com.funeat.fixture.ScoreFixture.점수_2점; import static com.funeat.fixture.ScoreFixture.점수_3점; +import static com.funeat.fixture.ScoreFixture.점수_4점; import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.review.exception.ReviewErrorCode.REVIEW_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.SoftAssertions.assertSoftly; @@ -300,6 +307,81 @@ class getMemberRecipes_실패_테스트 { } } + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 정상_처리_NO_CONTENT); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @ParameterizedTest + @NullAndEmptySource + void 로그인하지_않는_사용자가_리뷰_삭제시_예외가_발생한다(final String cookie) { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(cookie, 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 인증되지_않음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, LOGIN_MEMBER_NOT_FOUND.getCode(), LOGIN_MEMBER_NOT_FOUND.getMessage()); + } + + @Test + void 존재하지_않는_리뷰를_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버1), 리뷰2); + + // then + STATUS_CODE를_검증한다(응답, 찾을수_없음); + RESPONSE_CODE와_MESSAGE를_검증한다(응답, REVIEW_NOT_FOUND.getCode(), REVIEW_NOT_FOUND.getMessage()); + } + + @Test + void 자신이_작성하지_않은_리뷰는_삭제할_수_없다() { + // given + final var 카테고리 = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(카테고리); + final var 상품 = 단일_상품_저장(상품_삼각김밥_가격1000원_평점3점_생성(카테고리)); + final var 태그 = 단일_태그_저장(태그_맛있어요_TASTE_생성()); + 리뷰_작성_요청(로그인_쿠키_획득(멤버1), 상품, 사진_명세_요청(이미지1), 리뷰추가요청_재구매O_생성(점수_4점, List.of(태그))); + + // when + final var 응답 = 리뷰_삭제_요청(로그인_쿠키_획득(멤버2), 리뷰1); + + // then + STATUS_CODE를_검증한다(응답, 잘못된_요청); + } + } + private void 사용자_리뷰_조회_결과를_검증한다(final ExtractableResponse response, final int expectedReviewSize) { final var actual = response.jsonPath().getList("reviews", MemberReviewDto.class); diff --git a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java index ca5600fdc..681efb26a 100644 --- a/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java +++ b/backend/src/test/java/com/funeat/acceptance/member/MemberSteps.java @@ -62,4 +62,13 @@ public class MemberSteps { .then() .extract(); } + + public static ExtractableResponse 리뷰_삭제_요청(final String loginCookie, final Long reviewId) { + return given() + .cookie("JSESSIONID", loginCookie) + .when() + .delete("/api/members/reviews/{reviewId}", reviewId) + .then() + .extract(); + } } diff --git a/backend/src/test/java/com/funeat/common/EventTest.java b/backend/src/test/java/com/funeat/common/EventTest.java new file mode 100644 index 000000000..dec401bec --- /dev/null +++ b/backend/src/test/java/com/funeat/common/EventTest.java @@ -0,0 +1,66 @@ +package com.funeat.common; + +import com.funeat.member.domain.Member; +import com.funeat.member.persistence.MemberRepository; +import com.funeat.product.domain.Category; +import com.funeat.product.domain.Product; +import com.funeat.product.persistence.CategoryRepository; +import com.funeat.product.persistence.ProductRepository; +import com.funeat.review.application.ReviewService; +import com.funeat.review.persistence.ReviewRepository; +import com.funeat.tag.domain.Tag; +import com.funeat.tag.persistence.TagRepository; +import java.util.List; +import org.junit.jupiter.api.DisplayNameGeneration; +import org.junit.jupiter.api.DisplayNameGenerator.ReplaceUnderscores; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.test.context.event.RecordApplicationEvents; + +@SpringBootTest +@RecordApplicationEvents +@ExtendWith(MockitoExtension.class) +@DisplayNameGeneration(ReplaceUnderscores.class) +public class EventTest { + + @Autowired + protected ApplicationEvents events; + + @Autowired + protected ProductRepository productRepository; + + @Autowired + protected CategoryRepository categoryRepository; + + @Autowired + protected TagRepository tagRepository; + + @Autowired + protected MemberRepository memberRepository; + + @Autowired + protected ReviewRepository reviewRepository; + + @Autowired + protected ReviewService reviewService; + + protected Long 단일_상품_저장(final Product product) { + return productRepository.save(product).getId(); + } + + protected Long 단일_카테고리_저장(final Category category) { + return categoryRepository.save(category).getId(); + } + + protected void 복수_태그_저장(final Tag... tagsToSave) { + final var tags = List.of(tagsToSave); + tagRepository.saveAll(tags); + } + + protected Long 단일_멤버_저장(final Member member) { + return memberRepository.save(member).getId(); + } +} diff --git a/backend/src/test/java/com/funeat/common/TestImageUploader.java b/backend/src/test/java/com/funeat/common/TestImageUploader.java index 58d4ab6f8..642da2176 100644 --- a/backend/src/test/java/com/funeat/common/TestImageUploader.java +++ b/backend/src/test/java/com/funeat/common/TestImageUploader.java @@ -30,6 +30,10 @@ public String upload(final MultipartFile image) { } } + @Override + public void delete(final String fileName) { + } + private void deleteDirectory(Path directory) throws IOException { // 디렉토리 내부 파일 및 디렉토리 삭제 try (Stream pathStream = Files.walk(directory)) { diff --git a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java index 73ed00553..fcb190d28 100644 --- a/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java +++ b/backend/src/test/java/com/funeat/member/persistence/ReviewFavoriteRepositoryTest.java @@ -3,6 +3,7 @@ import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버3_생성; import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점3점_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test4_평점4점_재구매O_생성; import static com.funeat.fixture.ReviewFixture.리뷰_이미지test5_평점5점_재구매O_생성; @@ -11,6 +12,7 @@ import com.funeat.common.RepositoryTest; import com.funeat.member.domain.favorite.ReviewFavorite; +import java.util.List; import java.util.NoSuchElementException; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -106,4 +108,77 @@ class findByMemberAndReview_실패_테스트 { .isInstanceOf(NoSuchElementException.class); } } + + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_좋아요를_삭제할_수_있다() { + // given + final var member1 = 멤버_멤버1_생성(); + final var member2 = 멤버_멤버2_생성(); + final var member3 = 멤버_멤버3_생성(); + 복수_멤버_저장(member1, member2, member3); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review1 = 리뷰_이미지test4_평점4점_재구매O_생성(member1, product, 0L); + final var review2 = 리뷰_이미지test5_평점5점_재구매O_생성(member1, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewFavorite1_1 = ReviewFavorite.create(member1, review1, true); + final var reviewFavorite1_2 = ReviewFavorite.create(member2, review1, true); + final var reviewFavorite1_3 = ReviewFavorite.create(member3, review1, true); + final var reviewFavorite2_1 = ReviewFavorite.create(member1, review2, true); + final var reviewFavorite2_2 = ReviewFavorite.create(member2, review2, true); + 복수_리뷰_좋아요_저장(reviewFavorite1_1, reviewFavorite1_2, reviewFavorite1_3, reviewFavorite2_1, reviewFavorite2_2); + + final var expected = List.of(reviewFavorite2_1, reviewFavorite2_2); + + // when + reviewFavoriteRepository.deleteByReview(review1); + + // then + final var remainings = reviewFavoriteRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 리뷰로_해당_리뷰에_달린_좋아요를_조회할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점3점_생성(category); + 단일_상품_저장(product); + + final var review = 리뷰_이미지test4_평점4점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewFavorite = ReviewFavorite.create(member, review, true); + 단일_리뷰_좋아요_저장(reviewFavorite); + + final var expected = List.of(reviewFavorite); + + // when + final var actual = reviewFavoriteRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } } diff --git a/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java new file mode 100644 index 000000000..5edf33f36 --- /dev/null +++ b/backend/src/test/java/com/funeat/review/application/ReviewDeleteEventListenerTest.java @@ -0,0 +1,217 @@ +package com.funeat.review.application; + +import static com.funeat.fixture.CategoryFixture.카테고리_즉석조리_생성; +import static com.funeat.fixture.ImageFixture.이미지_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버1_생성; +import static com.funeat.fixture.MemberFixture.멤버_멤버2_생성; +import static com.funeat.fixture.ProductFixture.상품_삼각김밥_가격1000원_평점2점_생성; +import static com.funeat.fixture.ReviewFixture.리뷰추가요청_재구매O_생성; +import static com.funeat.fixture.TagFixture.태그_맛있어요_TASTE_생성; +import static com.funeat.fixture.TagFixture.태그_아침식사_ETC_생성; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.timeout; +import static org.mockito.Mockito.verify; + +import com.funeat.common.EventTest; +import com.funeat.common.ImageUploader; +import com.funeat.common.exception.CommonException.S3DeleteFailException; +import com.funeat.exception.CommonErrorCode; +import com.funeat.tag.domain.Tag; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.mock.mockito.MockBean; + +class ReviewDeleteEventListenerTest extends EventTest { + + @MockBean + private ImageUploader uploader; + + @Nested + class 리뷰_삭제_이벤트_발행 { + + @Test + void 리뷰_작성자가_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(1); + } + + @Test + void 리뷰_작성자가_아닌_사람이_리뷰_삭제_시도시_리뷰_삭제_이벤트가_발행되지_않는다() { + // given + final var author = 멤버_멤버2_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + try { + reviewService.deleteReview(reviewId, memberId); + } catch (Exception ignored) { + } + + // then + final var count = events.stream(ReviewDeleteEvent.class).count(); + assertThat(count).isEqualTo(0); + } + } + + @Nested + class 이미지_삭제_로직_작동 { + + @Test + void 리뷰_삭제가_정상적으로_커밋되고_이미지가_존재하면_이미지_삭제_로직이_작동한다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(1)).delete(any()); + } + + @Test + void 리뷰_삭제가_정상적으로_커밋되었지만_이미지가_존재하지_않으면_이미지_삭제_로직이_작동하지않는다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, null, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + verify(uploader, timeout(100).times(0)).delete(any()); + } + + @Test + void 이미지_삭제_로직이_실패해도_메인로직까지_롤백되어서는_안된다() { + // given + final var member = 멤버_멤버1_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + + final var request = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, memberId, image, request); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + doThrow(new S3DeleteFailException(CommonErrorCode.UNKNOWN_SERVER_ERROR_CODE)) + .when(uploader) + .delete(any()); + + // when + reviewService.deleteReview(reviewId, memberId); + + // then + assertThat(reviewRepository.findById(reviewId)).isEmpty(); + } + } + + private List 태그_아이디_변환(final Tag... tags) { + return Stream.of(tags) + .map(Tag::getId) + .collect(Collectors.toList()); + } +} diff --git a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java index 00c3ed691..e6ca43b22 100644 --- a/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java +++ b/backend/src/test/java/com/funeat/review/application/ReviewServiceTest.java @@ -40,6 +40,7 @@ import com.funeat.review.domain.Review; import com.funeat.review.dto.MostFavoriteReviewResponse; import com.funeat.review.dto.SortingReviewDto; +import com.funeat.review.exception.ReviewException.NotAuthorOfReviewException; import com.funeat.review.exception.ReviewException.ReviewNotFoundException; import com.funeat.tag.domain.Tag; import java.util.List; @@ -614,7 +615,7 @@ class updateProductImage_성공_테스트 { final var expected = review.getImage(); // when - reviewService.updateProductImage(reviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -643,7 +644,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -672,7 +673,7 @@ class updateProductImage_성공_테스트 { final var expected = firstReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -701,7 +702,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -722,11 +723,11 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var thirdReview = 리뷰_이미지test3_평점3점_재구매O_생성(member, product, 1L); final var thirdReviewId = 단일_리뷰_저장(thirdReview); @@ -734,7 +735,7 @@ class updateProductImage_성공_테스트 { final var expected = thirdReview.getImage(); // when - reviewService.updateProductImage(thirdReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -755,7 +756,7 @@ class updateProductImage_성공_테스트 { final var firstReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 3L); final var firstReviewId = 단일_리뷰_저장(firstReview); - reviewService.updateProductImage(firstReviewId); + reviewService.updateProductImage(product.getId()); final var secondReview = 리뷰_이미지없음_평점1점_재구매O_생성(member, product, 2L); final var secondReviewId = 단일_리뷰_저장(secondReview); @@ -763,7 +764,7 @@ class updateProductImage_성공_테스트 { final var expected = secondReview.getImage(); // when - reviewService.updateProductImage(secondReviewId); + reviewService.updateProductImage(product.getId()); final var actual = product.getImage(); // then @@ -775,7 +776,7 @@ class updateProductImage_성공_테스트 { class updateProductImage_실패_테스트 { @Test - void 존재하지_않는_리뷰로_상품_업데이트를_시도하면_예외가_발생한다() { + void 존재하지_않는_상품으로_상품_업데이트를_시도하면_예외가_발생한다() { // given final var member = 멤버_멤버1_생성(); 단일_멤버_저장(member); @@ -786,13 +787,155 @@ class updateProductImage_실패_테스트 { final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); 단일_상품_저장(product); - final var review = 리뷰_이미지test1_평점1점_재구매O_생성(member, product, 0L); - final var wrongReviewId = 단일_리뷰_저장(review) + 1L; + final var wrongProductId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.updateProductImage(wrongProductId)) + .isInstanceOf(ProductNotFoundException.class); + } + } + + @Nested + class deleteReview_성공_테스트 { + + @Test + void 자신이_작성한_리뷰를_삭제할_수_있다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var favoriteRequest = 리뷰좋아요요청_생성(true); + reviewService.likeReview(reviewId, authorId, favoriteRequest); + reviewService.likeReview(reviewId, memberId, favoriteRequest); + + // when + reviewService.deleteReview(reviewId, authorId); + + // then + final var tags = reviewTagRepository.findAll(); + final var favorites = reviewFavoriteRepository.findAll(); + final var findReview = reviewRepository.findById(reviewId); + + assertSoftly(soft -> { + soft.assertThat(tags).isEmpty(); + soft.assertThat(favorites).isEmpty(); + soft.assertThat(findReview).isEmpty(); + }); + } + } + + @Nested + class deleteReview_실패_테스트 { + + @Test + void 존재하지_않는_사용자가_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + final var wrongMemberId = 999L; + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, wrongMemberId)) + .isInstanceOf(MemberNotFoundException.class); + } + + @Test + void 존재하지_않는_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var wrongReviewId = 999L; // when & then - assertThatThrownBy(() -> reviewService.updateProductImage(wrongReviewId)) + assertThatThrownBy(() -> reviewService.deleteReview(wrongReviewId, authorId)) .isInstanceOf(ReviewNotFoundException.class); } + + @Test + void 자신이_작성하지_않은_리뷰를_삭제하려하면_에러가_발생한다() { + // given + final var author = 멤버_멤버1_생성(); + final var authorId = 단일_멤버_저장(author); + final var member = 멤버_멤버2_생성(); + final var memberId = 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격1000원_평점2점_생성(category); + final var productId = 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + final var tag2 = 태그_아침식사_ETC_생성(); + 복수_태그_저장(tag1, tag2); + + final var tagIds = 태그_아이디_변환(tag1, tag2); + final var image = 이미지_생성(); + final var reviewCreateRequest = 리뷰추가요청_재구매O_생성(4L, tagIds); + reviewService.create(productId, authorId, image, reviewCreateRequest); + + final var review = reviewRepository.findAll().get(0); + final var reviewId = review.getId(); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, memberId)) + .isInstanceOf(NotAuthorOfReviewException.class); + } } @Nested diff --git a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java index baab6abdb..4f5bbf152 100644 --- a/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java +++ b/backend/src/test/java/com/funeat/review/persistence/ReviewTagRepositoryTest.java @@ -72,6 +72,80 @@ class findTop3TagsByReviewIn_성공_테스트 { } } + @Nested + class deleteByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_삭제할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review1 = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + final var review2 = 리뷰_이미지test3_평점3점_재구매X_생성(member, product, 0L); + 복수_리뷰_저장(review1, review2); + + final var reviewTag1_1 = 리뷰_태그_생성(review1, tag1); + final var reviewTag2_1 = 리뷰_태그_생성(review2, tag1); + 복수_리뷰_태그_저장(reviewTag1_1, reviewTag2_1); + + final var expected = List.of(reviewTag2_1); + + // when + reviewTagRepository.deleteByReview(review1); + + // then + final var remainings = reviewTagRepository.findAll(); + assertThat(remainings).usingRecursiveComparison() + .isEqualTo(expected); + } + } + + @Nested + class findByReview_성공_테스트 { + + @Test + void 해당_리뷰에_달린_태그를_확인할_수_있다() { + // given + final var member = 멤버_멤버1_생성(); + 단일_멤버_저장(member); + + final var category = 카테고리_즉석조리_생성(); + 단일_카테고리_저장(category); + + final var product = 상품_삼각김밥_가격3000원_평점2점_생성(category); + 단일_상품_저장(product); + + final var tag1 = 태그_맛있어요_TASTE_생성(); + 단일_태그_저장(tag1); + + final var review = 리뷰_이미지test5_평점5점_재구매O_생성(member, product, 0L); + 단일_리뷰_저장(review); + + final var reviewTag = 리뷰_태그_생성(review, tag1); + 단일_리뷰_태그_저장(reviewTag); + + final var expected = List.of(reviewTag); + + // when + final var actual = reviewTagRepository.findByReview(review); + + // then + assertThat(actual).usingRecursiveComparison() + .ignoringExpectedNullFields() + .isEqualTo(expected); + } + } + private ReviewTag 리뷰_태그_생성(final Review review, final Tag tag) { return ReviewTag.createReviewTag(review, tag); }