diff --git a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java index a039c08b..26ce4b1f 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -86,6 +86,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/conversations/**").authenticated() .requestMatchers("/api/notifications/**").authenticated() .requestMatchers("/api/sse/**").authenticated() + .requestMatchers("/api/reviews/**").authenticated() .requestMatchers(HttpMethod.POST, "/api/auth/reset-password").permitAll() .requestMatchers("/api/auth/sign-in").permitAll() diff --git a/src/main/java/com/codeit/team5/mopl/config/WebConfig.java b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java index 5f3d20b5..6ab1d13e 100644 --- a/src/main/java/com/codeit/team5/mopl/config/WebConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/WebConfig.java @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.config; import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException; +import com.codeit.team5.mopl.review.contant.ReviewSortBy; import com.codeit.team5.mopl.user.constant.UserSortBy; import org.springframework.context.annotation.Configuration; import org.springframework.data.domain.Sort; @@ -23,5 +24,6 @@ public void addFormatters(FormatterRegistry registry) { }); registry.addConverter(String.class, UserSortBy.class, UserSortBy::from); + registry.addConverter(String.class, ReviewSortBy.class, ReviewSortBy::from); } } diff --git a/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java b/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java new file mode 100644 index 00000000..7e5a5c27 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java @@ -0,0 +1,36 @@ +package com.codeit.team5.mopl.review.contant; + +import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; +import com.codeit.team5.mopl.review.exception.ReviewSortByMismatchException; +import java.time.Instant; +import java.util.function.Function; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReviewSortBy { + CREATED_AT("createdAt", Instant::parse), + RATING("rating", Double::parseDouble), + ; + + private final String sortByType; + private final Function parser; + + public static ReviewSortBy from(String string) { + for (ReviewSortBy value : ReviewSortBy.values()) { + if (string.equalsIgnoreCase(value.sortByType)) { + return value; + } + } + throw new InvalidReviewSortByException(string); + } + + public Object parse(String cursor){ + try{ + return parser.apply(cursor); + } catch (Exception e){ + throw new ReviewSortByMismatchException(sortByType, cursor); + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java b/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java new file mode 100644 index 00000000..7958e634 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java @@ -0,0 +1,88 @@ +package com.codeit.team5.mopl.review.controller; + +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.review.controller.api.ReviewApi; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.service.ReviewService; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +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; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@Validated +@Slf4j +@RequestMapping("/api/reviews") +public class ReviewController implements ReviewApi { + + private final ReviewService reviewService; + + @Override + @GetMapping + public ResponseEntity> getReviews(@Valid ReviewGetRequest request) { + + log.info("리뷰 목록 조회: GET /api/reviews, contentId={}", request.contentId()); + + CursorResponse response = reviewService.getReviews(request); + + return ResponseEntity.ok(response); + } + + @Override + @PostMapping + public ResponseEntity createReview( + @AuthenticationPrincipal MoplPrincipal moplPrincipal, + @RequestBody @Valid ReviewCreateRequest request) { + + log.info("리뷰 생성: POST /api/reviews, authorId={}", moplPrincipal.getId()); + + ReviewResponse response = reviewService.createReview(moplPrincipal.getId(), request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @Override + @PatchMapping("/{reviewId}") + public ResponseEntity updateReview( + @AuthenticationPrincipal MoplPrincipal moplPrincipal, + @PathVariable UUID reviewId, + @RequestBody @Valid ReviewUpdateRequest request) { + + log.info("리뷰 수정: PATCH /api/reviews/{}, authorId={}", reviewId, moplPrincipal.getId()); + + ReviewResponse response = reviewService.updateReview(reviewId, moplPrincipal.getId(), request); + + return ResponseEntity.ok(response); + } + + @Override + @DeleteMapping("/{reviewId}") + public ResponseEntity deleteReview( + @AuthenticationPrincipal MoplPrincipal moplPrincipal, + @PathVariable UUID reviewId) { + + log.info("리뷰 삭제: DELETE /api/reviews/{}, authorId={}", reviewId, moplPrincipal.getId()); + + reviewService.deleteReview(reviewId, moplPrincipal.getId()); + + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/controller/api/ReviewApi.java b/src/main/java/com/codeit/team5/mopl/review/controller/api/ReviewApi.java new file mode 100644 index 00000000..333f3d61 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/controller/api/ReviewApi.java @@ -0,0 +1,92 @@ +package com.codeit.team5.mopl.review.controller.api; + +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.global.dto.suggestion.ErrorResponseSuggestion; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "리뷰 관리", description = "리뷰 API") +public interface ReviewApi { + + @Operation(operationId = "getReviews", summary = "리뷰 목록 조회 (커서 페이지네이션)") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity> getReviews( + @Valid ReviewGetRequest request); + + @Operation(operationId = "createReview", summary = "리뷰 생성", + description = "생성한 리뷰는 API 요청자 본인의 리뷰로 생성됩니다.") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "409", description = "이미 리뷰 작성함", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity createReview( + @Parameter(hidden = true) MoplPrincipal moplPrincipal, + @RequestBody @Valid ReviewCreateRequest request); + + @Operation(operationId = "updateReview", summary = "리뷰 수정") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "404", description = "리뷰 없음", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity updateReview( + @Parameter(hidden = true) MoplPrincipal moplPrincipal, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable UUID reviewId, + @RequestBody @Valid ReviewUpdateRequest request); + + @Operation(operationId = "deleteReview", summary = "리뷰 삭제") + @ApiResponses({ + @ApiResponse(responseCode = "204", description = "삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "403", description = "권한 없음", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "404", description = "리뷰 없음", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))), + @ApiResponse(responseCode = "500", description = "서버 오류", + content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) + }) + ResponseEntity deleteReview( + @Parameter(hidden = true) MoplPrincipal moplPrincipal, + @Parameter(description = "리뷰 ID", required = true) + @PathVariable UUID reviewId); +} diff --git a/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewCreateRequest.java b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewCreateRequest.java new file mode 100644 index 00000000..0247705a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewCreateRequest.java @@ -0,0 +1,26 @@ +package com.codeit.team5.mopl.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; + +@Schema(name = "ReviewCreateRequest", description = "리뷰 생성 요청") +public record ReviewCreateRequest( + @Schema(description = "콘텐츠 ID", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "contentId는 필수입니다.") + UUID contentId, + + @Schema(description = "리뷰 내용", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "리뷰 내용은 필수입니다.") + String text, + + @Schema(description = "평점 (0.0 ~ 5.0)", requiredMode = Schema.RequiredMode.REQUIRED) + @NotNull(message = "rating은 필수입니다.") + @DecimalMin(value = "0.0", message = "평점은 0.0 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.") + Double rating +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewGetRequest.java b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewGetRequest.java new file mode 100644 index 00000000..1445e20b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewGetRequest.java @@ -0,0 +1,26 @@ +package com.codeit.team5.mopl.review.dto.request; + +import com.codeit.team5.mopl.review.contant.ReviewSortBy; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import java.util.UUID; +import org.springframework.data.domain.Sort; + +public record ReviewGetRequest( + @NotNull + UUID contentId, + + String cursor, + + UUID idAfter, + + @Min(1) + @Max(100) + Integer limit, + + Sort.Direction sortDirection, + + ReviewSortBy sortBy +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewUpdateRequest.java b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewUpdateRequest.java new file mode 100644 index 00000000..d6542059 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewUpdateRequest.java @@ -0,0 +1,17 @@ +package com.codeit.team5.mopl.review.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; + +@Schema(name = "ReviewUpdateRequest", description = "리뷰 수정 요청") +public record ReviewUpdateRequest( + @Schema(description = "리뷰 내용") + String text, + + @Schema(description = "평점 (0.0 ~ 5.0)") + @DecimalMin(value = "0.0", message = "평점은 0.0 이상이어야 합니다.") + @DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.") + Double rating +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/review/dto/response/ReviewResponse.java b/src/main/java/com/codeit/team5/mopl/review/dto/response/ReviewResponse.java new file mode 100644 index 00000000..c84a2c58 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/dto/response/ReviewResponse.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.review.dto.response; + +import com.codeit.team5.mopl.user.dto.response.UserSummaryResponse; +import java.util.UUID; + +public record ReviewResponse( + UUID id, + UUID contentId, + UserSummaryResponse author, + String text, + Double rating +) { +} diff --git a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java new file mode 100644 index 00000000..0c310012 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -0,0 +1,66 @@ +package com.codeit.team5.mopl.review.entity; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; +import com.codeit.team5.mopl.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "reviews", + uniqueConstraints = @UniqueConstraint(columnNames = {"content_id", "user_id"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseUpdatableEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + private Content content; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User author; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column(nullable = false) + private Double rating; + + public static Review of(Content content, User author, String text, Double rating) { + return new Review(content, author, text, rating); + } + + private Review(Content content, User author, String text, Double rating) { + this.content = content; + this.author = author; + this.text = text; + this.rating = rating; + } + + public UUID getContentId() { + return content.getId(); + } + + public UUID getAuthorId() { + return author.getId(); + } + + public void update(String text, Double rating) { + if (text != null) { + this.text = text; + } + if(rating != null){ + this.rating = rating; + } + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/CursorIdAfterNotTogetherException.java b/src/main/java/com/codeit/team5/mopl/review/exception/CursorIdAfterNotTogetherException.java new file mode 100644 index 00000000..2bad45ef --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/CursorIdAfterNotTogetherException.java @@ -0,0 +1,13 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class CursorIdAfterNotTogetherException extends BusinessException { + + + public CursorIdAfterNotTogetherException() { + + super(HttpStatus.BAD_REQUEST, "cursor와 idAfter가 같이 제공되지 않습니다."); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java new file mode 100644 index 00000000..ee914bd5 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidCursorException extends BusinessException { + + public InvalidCursorException() { + super(HttpStatus.BAD_REQUEST, "커서가 유효하지 않습니다."); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidReviewSortByException.java b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidReviewSortByException.java new file mode 100644 index 00000000..8fdf0f3a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidReviewSortByException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidReviewSortByException extends BusinessException { + + public InvalidReviewSortByException(String sortBy) { + super(HttpStatus.BAD_REQUEST, "지원하지 않는 정렬 기준입니다 (createdAt 또는 rating): " + sortBy); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/ReviewAlreadyExistsException.java b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewAlreadyExistsException.java new file mode 100644 index 00000000..eba14c45 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class ReviewAlreadyExistsException extends BusinessException { + + public ReviewAlreadyExistsException() { + super(HttpStatus.CONFLICT, "이미 해당 콘텐츠에 리뷰를 작성했습니다."); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/ReviewForbiddenException.java b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewForbiddenException.java new file mode 100644 index 00000000..23e7ebcd --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewForbiddenException.java @@ -0,0 +1,11 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class ReviewForbiddenException extends BusinessException { + + public ReviewForbiddenException() { + super(HttpStatus.FORBIDDEN, "리뷰를 수정/삭제할 권한이 없습니다."); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/ReviewNotFoundException.java b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewNotFoundException.java new file mode 100644 index 00000000..5444c49c --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewNotFoundException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.UUID; +import org.springframework.http.HttpStatus; + +public class ReviewNotFoundException extends BusinessException { + + public ReviewNotFoundException(UUID id) { + super(HttpStatus.NOT_FOUND, "리뷰를 찾을 수 없습니다: id=" + id); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java new file mode 100644 index 00000000..10baf123 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java @@ -0,0 +1,14 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import java.util.Map; +import org.springframework.http.HttpStatus; + +public class ReviewSortByMismatchException extends BusinessException { + + public ReviewSortByMismatchException(String sortByType, String cursor) { + super(HttpStatus.BAD_REQUEST, + "정렬 기준과 커서 타입이 일치하지 않습니다. (허용 포맷: createdAt -> 예: 2026-07-01T10:00:00Z, rating -> double형 숫자)", + Map.of("sortBy", sortByType, "cursor", cursor)); + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java new file mode 100644 index 00000000..1b4624a0 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java @@ -0,0 +1,19 @@ +package com.codeit.team5.mopl.review.mapper; + +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.user.mapper.UserMapper; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.ERROR, + uses = {UserMapper.class} +) +public interface ReviewMapper { + + @Mapping(target = "author", source = "author") + ReviewResponse toDto(Review review); +} diff --git a/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java new file mode 100644 index 00000000..661a88c8 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java @@ -0,0 +1,19 @@ +package com.codeit.team5.mopl.review.repository; + +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.review.repository.querydsl.ReviewQueryRepository; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { + + @Query("SELECT r FROM Review r JOIN FETCH r.author WHERE r.id = :id") + Optional findByIdWithAuthor(@Param("id") UUID id); + + boolean existsByContent_IdAndAuthor_Id(UUID contentId, UUID authorId); + + long countByContent_Id(UUID contentId); +} diff --git a/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepository.java b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepository.java new file mode 100644 index 00000000..aa400c88 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepository.java @@ -0,0 +1,16 @@ +package com.codeit.team5.mopl.review.repository.querydsl; + +import com.codeit.team5.mopl.review.entity.Review; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; + +public interface ReviewQueryRepository { + + List findPageByContentIdSortByCreatedAt(UUID contentId, Instant cursor, UUID idAfter, Limit limit, Sort.Direction sortDirection); + + List findPageByContentIdSortByRating(UUID contentId, Double cursor, UUID idAfter, Limit limit, Sort.Direction sortDirection); + +} diff --git a/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepositoryImpl.java b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepositoryImpl.java new file mode 100644 index 00000000..b0150f5b --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepositoryImpl.java @@ -0,0 +1,73 @@ +package com.codeit.team5.mopl.review.repository.querydsl; + +import com.codeit.team5.mopl.review.entity.QReview; +import com.codeit.team5.mopl.review.entity.Review; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { + + private final JPAQueryFactory queryFactory; + + private static final QReview r = QReview.review; + + @Override + public List findPageByContentIdSortByRating(UUID contentId, Double cursor, UUID idAfter, + Limit limit, Sort.Direction sortDirection) { + + BooleanExpression cursorCondition = null; + if (cursor != null) { + cursorCondition = sortDirection.isDescending() + ? r.rating.lt(cursor).or(r.rating.eq(cursor).and(r.id.gt(idAfter))) + : r.rating.gt(cursor).or(r.rating.eq(cursor).and(r.id.gt(idAfter))); + } + + return queryFactory.selectFrom(r) + .join(r.author).fetchJoin() + .where(r.content.id.eq(contentId), cursorCondition) + .orderBy(ratingOrder(sortDirection)) + .limit(limit.max()) + .fetch(); + } + + @Override + public List findPageByContentIdSortByCreatedAt(UUID contentId, Instant cursor, UUID idAfter, + Limit limit, Sort.Direction sortDirection) { + + BooleanExpression cursorCondition = null; + if (cursor != null) { + cursorCondition = sortDirection.isDescending() + ? r.createdAt.lt(cursor).or(r.createdAt.eq(cursor).and(r.id.gt(idAfter))) + : r.createdAt.gt(cursor).or(r.createdAt.eq(cursor).and(r.id.gt(idAfter))); + } + + return queryFactory.selectFrom(r) + .join(r.author).fetchJoin() + .where(r.content.id.eq(contentId), cursorCondition) + .orderBy(createdAtOrder(sortDirection)) + .limit(limit.max()) + .fetch(); + } + + private OrderSpecifier[] ratingOrder(Sort.Direction direction) { + return direction.isDescending() + ? new OrderSpecifier[] {r.rating.desc(), r.id.asc()} + : new OrderSpecifier[] {r.rating.asc(), r.id.asc()}; + } + + private OrderSpecifier[] createdAtOrder(Sort.Direction direction) { + return direction.isDescending() + ? new OrderSpecifier[] {r.createdAt.desc(), r.id.asc()} + : new OrderSpecifier[] {r.createdAt.asc(), r.id.asc()}; + } +} diff --git a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java new file mode 100644 index 00000000..d73d3448 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -0,0 +1,156 @@ +package com.codeit.team5.mopl.review.service; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.global.dto.CursorResponse; + +import com.codeit.team5.mopl.review.contant.ReviewSortBy; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.review.exception.CursorIdAfterNotTogetherException; +import com.codeit.team5.mopl.review.exception.InvalidCursorException; +import com.codeit.team5.mopl.review.exception.ReviewAlreadyExistsException; +import com.codeit.team5.mopl.review.exception.ReviewForbiddenException; +import com.codeit.team5.mopl.review.exception.ReviewNotFoundException; +import com.codeit.team5.mopl.review.mapper.ReviewMapper; +import com.codeit.team5.mopl.review.repository.ReviewRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.exception.UserNotFoundException; +import com.codeit.team5.mopl.user.repository.UserRepository; +import java.time.Instant; +import java.time.format.DateTimeParseException; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort.Direction; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private static final int DEFAULT_LIMIT = 20; + private static final Direction DEFAULT_DIRECTION = Direction.DESC; + private static final ReviewSortBy DEFAULT_SORT_BY = ReviewSortBy.CREATED_AT; + + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final ContentRepository contentRepository; + private final ReviewMapper reviewMapper; + + @Transactional(readOnly = true) + public CursorResponse getReviews(ReviewGetRequest request) { + UUID contentId = request.contentId(); + String cursor = (request.cursor() != null && !request.cursor().isBlank()) ? request.cursor() : null; + UUID idAfter = request.idAfter(); + int limit = request.limit() != null ? request.limit() : DEFAULT_LIMIT; + Direction direction = request.sortDirection() != null ? request.sortDirection() : DEFAULT_DIRECTION; + ReviewSortBy sortBy = request.sortBy() != null ? request.sortBy() : DEFAULT_SORT_BY; + + validateCursorIdAfterPair(cursor, idAfter); + + Limit fetchLimit = Limit.of(limit + 1); + + List rows; + + if (sortBy == ReviewSortBy.CREATED_AT) { + Instant createdAtCursor = null; + if (cursor != null) { + createdAtCursor = parseInstantCursor(cursor); + } + rows = reviewRepository.findPageByContentIdSortByCreatedAt(contentId, createdAtCursor, idAfter, fetchLimit, direction); + } else { + Double ratingCursor = null; + if (cursor != null) { + ratingCursor = parseDoubleCursor(cursor); + } + rows = reviewRepository.findPageByContentIdSortByRating(contentId, ratingCursor, idAfter, fetchLimit, direction); + } + + boolean hasNext = rows.size() > limit; + List page = hasNext ? rows.subList(0, limit) : rows; + + String nextCursor = null; + String nextIdAfter = null; + if (hasNext && !page.isEmpty()) { + Review last = page.get(page.size() - 1); + nextCursor = sortBy == ReviewSortBy.RATING + ? last.getRating().toString() + : last.getCreatedAt().toString(); + nextIdAfter = last.getId().toString(); + } + + long totalCount = reviewRepository.countByContent_Id(contentId); + + List data = page.stream() + .map(reviewMapper::toDto) + .toList(); + + return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, + sortBy.getSortByType(), direction.toString()); + } + + @Transactional + public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { + Content content = contentRepository.findById(request.contentId()) + .orElseThrow(() -> new ContentNotFoundException(request.contentId())); + User author = userRepository.findById(authorId) + .orElseThrow(() -> new UserNotFoundException(authorId)); + + if (reviewRepository.existsByContent_IdAndAuthor_Id(request.contentId(), authorId)) { + throw new ReviewAlreadyExistsException(); + } + + Review saved = reviewRepository.save(Review.of(content, author, request.text(), request.rating())); + log.info("리뷰 생성 완료: reviewId={}, contentId={}, authorId={}", saved.getId(), saved.getContentId(), authorId); + return reviewMapper.toDto(saved); + } + + @Transactional + public ReviewResponse updateReview(UUID reviewId, UUID authorId, ReviewUpdateRequest request) { + Review review = reviewRepository.findByIdWithAuthor(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + if (!review.getAuthorId().equals(authorId)) { + log.warn("리뷰 수정 권한 없음: reviewId={}, requesterId={}", reviewId, authorId); + throw new ReviewForbiddenException(); + } + review.update(request.text(), request.rating()); + log.info("리뷰 수정 완료: reviewId={}, authorId={}", reviewId, authorId); + return reviewMapper.toDto(review); + } + + @Transactional + public void deleteReview(UUID reviewId, UUID authorId) { + Review review = reviewRepository.findByIdWithAuthor(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + if (!review.getAuthorId().equals(authorId)) { + log.warn("리뷰 삭제 권한 없음: reviewId={}, requesterId={}", reviewId, authorId); + throw new ReviewForbiddenException(); + } + reviewRepository.delete(review); + log.info("리뷰 삭제 완료: reviewId={}, authorId={}", reviewId, authorId); + } + + private void validateCursorIdAfterPair(String cursor, UUID idAfter) { + if ((cursor != null && idAfter == null) || (cursor == null && idAfter != null)) { + throw new CursorIdAfterNotTogetherException(); + } + } + + private Instant parseInstantCursor(String cursor) { + return (Instant) ReviewSortBy.CREATED_AT.parse(cursor); + } + + private Double parseDoubleCursor(String cursor) { + return (Double) ReviewSortBy.RATING.parse(cursor); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java new file mode 100644 index 00000000..2eb372cc --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java @@ -0,0 +1,349 @@ +package com.codeit.team5.mopl.review.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codeit.team5.mopl.TestcontainersConfiguration; +import com.codeit.team5.mopl.auth.security.details.AuthUser; +import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.review.repository.ReviewRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestcontainersConfiguration.class) +@Transactional +class ReviewControllerIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Autowired + private UserRepository userRepository; + + @Autowired + private ContentRepository contentRepository; + + @Autowired + private ReviewRepository reviewRepository; + + private User persistUser(String email) { + return userRepository.saveAndFlush(User.create(email, "password", "테스터")); + } + + private Content persistContent(String title) { + return contentRepository.saveAndFlush( + Content.createByAdmin(ContentType.MOVIE, title, null)); + } + + private Review persistReview(Content content, User author, String text, double rating) { + return reviewRepository.saveAndFlush(Review.of(content, author, text, rating)); + } + + private Authentication authOf(UUID userId, String email) { + MoplUserDetails details = new MoplUserDetails( + new AuthUser(userId, email, "USER", false), "password"); + return new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities()); + } + + // ===== GET /api/reviews ===== + + @Test + @DisplayName("리뷰 목록 조회에 성공하고 응답에 리뷰 데이터와 페이지 메타가 포함된다") + void getReviews_success() throws Exception { + // given + User author = persistUser("list@example.com"); + Content content = persistContent("영화1"); + Review saved = persistReview(content, author, "재밌어요", 4.5); + + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(author.getId(), "list@example.com"))) + .param("contentId", content.getId().toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(saved.getId().toString())) + .andExpect(jsonPath("$.data[0].contentId").value(content.getId().toString())) + .andExpect(jsonPath("$.data[0].author.userId").value(author.getId().toString())) + .andExpect(jsonPath("$.data[0].text").value("재밌어요")) + .andExpect(jsonPath("$.data[0].rating").value(4.5)) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("다른 콘텐츠의 리뷰는 조회되지 않는다") + void getReviews_filtersByContentId() throws Exception { + // given + User author = persistUser("filter@example.com"); + Content myContent = persistContent("내 영화"); + Content otherContent = persistContent("다른 영화"); + persistReview(myContent, author, "내 리뷰", 4.0); + persistReview(otherContent, author, "다른 리뷰", 3.0); + + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(author.getId(), "filter@example.com"))) + .param("contentId", myContent.getId().toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.data[0].text").value("내 리뷰")); + } + + @Test + @DisplayName("커서 페이지네이션으로 다음 페이지를 조회한다") + void getReviews_cursorPagination() throws Exception { + // given + User author1 = persistUser("page1@example.com"); + User author2 = persistUser("page2@example.com"); + User author3 = persistUser("page3@example.com"); + Content content = persistContent("영화2"); + persistReview(content, author1, "리뷰1", 5.0); + persistReview(content, author2, "리뷰2", 4.0); + persistReview(content, author3, "리뷰3", 3.0); + + // when: 첫 페이지 (limit=2) + String firstPageJson = mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(author1.getId(), "page1@example.com"))) + .param("contentId", content.getId().toString()) + .param("limit", "2") + .param("sortBy", "createdAt") + .param("sortDirection", "DESC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(true)) + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.nextCursor").isNotEmpty()) + .andExpect(jsonPath("$.nextIdAfter").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + + com.fasterxml.jackson.databind.JsonNode firstPage = + objectMapper.readTree(firstPageJson); + String nextCursor = firstPage.get("nextCursor").asText(); + String nextIdAfter = firstPage.get("nextIdAfter").asText(); + + // then: 두 번째 페이지 + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(author1.getId(), "page1@example.com"))) + .param("contentId", content.getId().toString()) + .param("cursor", nextCursor) + .param("idAfter", nextIdAfter) + .param("limit", "2") + .param("sortBy", "createdAt") + .param("sortDirection", "DESC")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.length()").value(1)) + .andExpect(jsonPath("$.hasNext").value(false)); + } + + @Test + @DisplayName("인증 없이 조회하면 401을 반환한다") + void getReviews_unauthenticated_returns401() throws Exception { + // when & then + mockMvc.perform(get("/api/reviews") + .param("contentId", UUID.randomUUID().toString())) + .andExpect(status().isUnauthorized()); + } + + // ===== POST /api/reviews ===== + + @Test + @DisplayName("리뷰 생성에 성공하고 DB에 저장된다") + void createReview_success() throws Exception { + // given + User author = persistUser("create@example.com"); + Content content = persistContent("영화3"); + ReviewCreateRequest request = new ReviewCreateRequest(content.getId(), "최고예요", 5.0); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(author.getId(), "create@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.contentId").value(content.getId().toString())) + .andExpect(jsonPath("$.author.userId").value(author.getId().toString())) + .andExpect(jsonPath("$.text").value("최고예요")) + .andExpect(jsonPath("$.rating").value(5.0)); + + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠에 리뷰 생성하면 404를 반환한다") + void createReview_contentNotFound_returns404() throws Exception { + // given + User author = persistUser("notfound@example.com"); + ReviewCreateRequest request = new ReviewCreateRequest(UUID.randomUUID(), "리뷰", 4.0); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(author.getId(), "notfound@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")); + } + + @Test + @DisplayName("같은 콘텐츠에 두 번 리뷰 생성하면 409를 반환한다") + void createReview_duplicate_returns409() throws Exception { + // given + User author = persistUser("dup@example.com"); + Content content = persistContent("영화4"); + persistReview(content, author, "첫 리뷰", 4.0); + ReviewCreateRequest request = new ReviewCreateRequest(content.getId(), "두 번째 리뷰", 3.0); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(author.getId(), "dup@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.exceptionType").value("ReviewAlreadyExistsException")); + } + + // ===== PATCH /api/reviews/{reviewId} ===== + + @Test + @DisplayName("리뷰 수정에 성공하고 DB에 반영된다") + void updateReview_success() throws Exception { + // given + User author = persistUser("update@example.com"); + Content content = persistContent("영화5"); + Review review = persistReview(content, author, "원래 내용", 3.0); + ReviewUpdateRequest request = new ReviewUpdateRequest("수정된 내용", 4.0); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", review.getId()) + .with(authentication(authOf(author.getId(), "update@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.text").value("수정된 내용")) + .andExpect(jsonPath("$.rating").value(4.0)); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 수정 시 403을 반환한다") + void updateReview_forbidden_returns403() throws Exception { + // given + User owner = persistUser("owner@example.com"); + User attacker = persistUser("attacker@example.com"); + Content content = persistContent("영화6"); + Review review = persistReview(content, owner, "원래 내용", 3.0); + ReviewUpdateRequest request = new ReviewUpdateRequest("악의적 수정", 1.0); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", review.getId()) + .with(authentication(authOf(attacker.getId(), "attacker@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("ReviewForbiddenException")); + } + + @Test + @DisplayName("존재하지 않는 리뷰 수정 시 404를 반환한다") + void updateReview_notFound_returns404() throws Exception { + // given + User author = persistUser("upnotfound@example.com"); + ReviewUpdateRequest request = new ReviewUpdateRequest("수정 내용", 3.0); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", UUID.randomUUID()) + .with(authentication(authOf(author.getId(), "upnotfound@example.com"))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ReviewNotFoundException")); + } + + // ===== DELETE /api/reviews/{reviewId} ===== + + @Test + @DisplayName("리뷰 삭제에 성공하고 DB에서 제거된다") + void deleteReview_success() throws Exception { + // given + User author = persistUser("delete@example.com"); + Content content = persistContent("영화7"); + Review review = persistReview(content, author, "삭제할 리뷰", 3.0); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", review.getId()) + .with(authentication(authOf(author.getId(), "delete@example.com"))) + .with(csrf())) + .andExpect(status().isNoContent()); + + assertThat(reviewRepository.findById(review.getId())).isEmpty(); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 삭제 시 403을 반환한다") + void deleteReview_forbidden_returns403() throws Exception { + // given + User owner = persistUser("delowner@example.com"); + User attacker = persistUser("delattacker@example.com"); + Content content = persistContent("영화8"); + Review review = persistReview(content, owner, "남의 리뷰", 4.0); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", review.getId()) + .with(authentication(authOf(attacker.getId(), "delattacker@example.com"))) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("ReviewForbiddenException")); + + assertThat(reviewRepository.findById(review.getId())).isPresent(); + } + + @Test + @DisplayName("존재하지 않는 리뷰 삭제 시 404를 반환한다") + void deleteReview_notFound_returns404() throws Exception { + // given + User author = persistUser("delnotfound@example.com"); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", UUID.randomUUID()) + .with(authentication(authOf(author.getId(), "delnotfound@example.com"))) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ReviewNotFoundException")); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java new file mode 100644 index 00000000..ede28969 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java @@ -0,0 +1,492 @@ +package com.codeit.team5.mopl.review.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.codeit.team5.mopl.TestGlobalExceptionHandlerConfig; +import com.codeit.team5.mopl.auth.jwt.JwtAuthenticationFilter; +import com.codeit.team5.mopl.auth.jwt.JwtAuthenticationService; +import com.codeit.team5.mopl.auth.jwt.JwtTokenizer; +import com.codeit.team5.mopl.auth.security.details.AuthUser; +import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; +import com.codeit.team5.mopl.auth.security.details.MoplUserDetailsService; +import com.codeit.team5.mopl.auth.security.handler.UserAccessDeniedHandler; +import com.codeit.team5.mopl.auth.security.handler.UserAuthenticationEntryPoint; +import com.codeit.team5.mopl.auth.security.handler.signin.SignInFailureHandler; +import com.codeit.team5.mopl.auth.security.handler.signin.SignInSuccessHandler; +import com.codeit.team5.mopl.auth.security.handler.signout.SignOutHandler; +import com.codeit.team5.mopl.auth.security.provider.MoplAuthenticationProvider; +import com.codeit.team5.mopl.config.SecurityConfig; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.global.exception.GlobalExceptionHandler; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.exception.ReviewAlreadyExistsException; +import com.codeit.team5.mopl.review.exception.ReviewForbiddenException; +import com.codeit.team5.mopl.review.exception.ReviewNotFoundException; +import com.codeit.team5.mopl.review.service.ReviewService; +import com.codeit.team5.mopl.user.dto.response.UserResponse; +import com.codeit.team5.mopl.user.dto.response.UserSummaryResponse; +import com.codeit.team5.mopl.user.repository.UserRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ReviewController.class) +@Import({ + GlobalExceptionHandler.class, + TestGlobalExceptionHandlerConfig.class, + SecurityConfig.class, + JwtAuthenticationFilter.class, + UserAuthenticationEntryPoint.class, + UserAccessDeniedHandler.class +}) +class ReviewControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockitoBean + private ReviewService reviewService; + + @MockitoBean + private JwtTokenizer jwtTokenizer; + + @MockitoBean + private JwtAuthenticationService jwtAuthenticationService; + + @MockitoBean + private MoplUserDetailsService userDetailsService; + + @MockitoBean + private MoplAuthenticationProvider moplAuthenticationProvider; + + @MockitoBean + private SignInSuccessHandler signInSuccessHandler; + + @MockitoBean + private SignInFailureHandler signInFailureHandler; + + @MockitoBean + private SignOutHandler signOutHandler; + + @MockitoBean + private UserRepository userRepository; + + private Authentication authOf(UUID userId) { + UserResponse dto = new UserResponse( + userId, Instant.now(), "user@mopl.com", "유저", null, "USER", false); + MoplUserDetails details = new MoplUserDetails( + new AuthUser(dto.id(), dto.email(), dto.role(), dto.locked()), "password"); + return new UsernamePasswordAuthenticationToken(details, null, details.getAuthorities()); + } + + private ReviewResponse sampleReviewResponse(UUID reviewId, UUID contentId, UUID authorId) { + UserSummaryResponse author = new UserSummaryResponse(authorId, "유저", null); + return new ReviewResponse(reviewId, contentId, author, "재밌어요", 4.5); + } + + // ===== GET /api/reviews ===== + + @Test + @DisplayName("리뷰 목록 조회에 성공하면 200과 커서 응답을 반환한다") + void getReviews_success() throws Exception { + // given + UUID contentId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + ReviewResponse review = sampleReviewResponse(reviewId, contentId, authorId); + CursorResponse response = new CursorResponse<>( + List.of(review), null, null, false, 1L, "createdAt", "DESCENDING"); + + given(reviewService.getReviews(any(ReviewGetRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .param("contentId", contentId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].id").value(reviewId.toString())) + .andExpect(jsonPath("$.data[0].contentId").value(contentId.toString())) + .andExpect(jsonPath("$.data[0].author.userId").value(authorId.toString())) + .andExpect(jsonPath("$.data[0].text").value("재밌어요")) + .andExpect(jsonPath("$.data[0].rating").value(4.5)) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.sortBy").value("createdAt")) + .andExpect(jsonPath("$.sortDirection").value("DESCENDING")); + } + + @Test + @DisplayName("커서와 정렬 옵션을 지정하면 서비스에 전달되고 200을 반환한다") + void getReviews_withCursorAndSort_success() throws Exception { + // given + UUID contentId = UUID.randomUUID(); + String cursor = "2026-01-01T00:00:00Z"; + UUID idAfter = UUID.randomUUID(); + + CursorResponse response = new CursorResponse<>( + List.of(), null, null, false, 0L, "rating", "ASCENDING"); + + given(reviewService.getReviews(any(ReviewGetRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .param("contentId", contentId.toString()) + .param("cursor", cursor) + .param("idAfter", idAfter.toString()) + .param("limit", "10") + .param("sortDirection", "ASC") + .param("sortBy", "rating")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.hasNext").value(false)) + .andExpect(jsonPath("$.data").isEmpty()); + } + + @Test + @DisplayName("limit이 0이면 400을 반환한다") + void getReviews_limitZero_returns400() throws Exception { + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .param("contentId", UUID.randomUUID().toString()) + .param("limit", "0")) + .andExpect(status().isBadRequest()); + + verify(reviewService, never()).getReviews(any(ReviewGetRequest.class)); + } + + @Test + @DisplayName("limit이 100 초과이면 400을 반환한다") + void getReviews_limitOver100_returns400() throws Exception { + // when & then + mockMvc.perform(get("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .param("contentId", UUID.randomUUID().toString()) + .param("limit", "101")) + .andExpect(status().isBadRequest()); + + verify(reviewService, never()).getReviews(any(ReviewGetRequest.class)); + } + + // ===== POST /api/reviews ===== + + @Test + @DisplayName("리뷰 생성에 성공하면 201과 생성된 리뷰를 반환한다") + void ofReview_success() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "재밌어요", 4.5); + ReviewResponse response = sampleReviewResponse(reviewId, contentId, authorId); + + given(reviewService.createReview(eq(authorId), any(ReviewCreateRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(reviewId.toString())) + .andExpect(jsonPath("$.contentId").value(contentId.toString())) + .andExpect(jsonPath("$.text").value("재밌어요")) + .andExpect(jsonPath("$.rating").value(4.5)); + } + + @Test + @DisplayName("인증 없이 리뷰 생성하면 401을 반환한다") + void ofReview_unauthenticated_returns401() throws Exception { + // given + ReviewCreateRequest request = new ReviewCreateRequest(UUID.randomUUID(), "재밌어요", 4.5); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("contentId가 없으면 400을 반환한다") + void ofReview_missingContentId_returns400() throws Exception { + // given + String body = "{\"text\":\"재밌어요\",\"rating\":4.5}"; + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("평점이 5.0 초과이면 400을 반환한다") + void ofReview_ratingOver5_returns400() throws Exception { + // given + ReviewCreateRequest request = new ReviewCreateRequest(UUID.randomUUID(), "재밌어요", 5.1); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("평점이 0.0 미만이면 400을 반환한다") + void ofReview_ratingUnder0_returns400() throws Exception { + // given + ReviewCreateRequest request = new ReviewCreateRequest(UUID.randomUUID(), "재밌어요", -0.1); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(UUID.randomUUID()))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("이미 리뷰를 작성했으면 409를 반환한다") + void ofReview_alreadyExists_returns409() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(UUID.randomUUID(), "재밌어요", 4.5); + + given(reviewService.createReview(eq(authorId), any())).willThrow(new ReviewAlreadyExistsException()); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.exceptionType").value("ReviewAlreadyExistsException")); + } + + @Test + @DisplayName("존재하지 않는 콘텐츠에 리뷰 생성하면 404를 반환한다") + void ofReview_contentNotFound_returns404() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "재밌어요", 4.5); + + given(reviewService.createReview(eq(authorId), any())).willThrow(new ContentNotFoundException(contentId)); + + // when & then + mockMvc.perform(post("/api/reviews") + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ContentNotFoundException")); + } + + // ===== PATCH /api/reviews/{reviewId} ===== + + @Test + @DisplayName("리뷰 수정에 성공하면 200과 수정된 리뷰를 반환한다") + void updateReview_success() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + + ReviewUpdateRequest request = new ReviewUpdateRequest("수정된 내용", 3.0); + ReviewResponse response = new ReviewResponse(reviewId, contentId, + new UserSummaryResponse(authorId, "유저", null), "수정된 내용", 3.0); + + given(reviewService.updateReview(eq(reviewId), eq(authorId), any(ReviewUpdateRequest.class))) + .willReturn(response); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(reviewId.toString())) + .andExpect(jsonPath("$.text").value("수정된 내용")) + .andExpect(jsonPath("$.rating").value(3.0)); + } + + @Test + @DisplayName("인증 없이 리뷰 수정하면 401을 반환한다") + void updateReview_unauthenticated_returns401() throws Exception { + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", UUID.randomUUID()) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReviewUpdateRequest("내용", 3.0)))) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).updateReview(any(), any(), any()); + } + + @Test + @DisplayName("존재하지 않는 리뷰 수정 시 404를 반환한다") + void updateReview_notFound_returns404() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + given(reviewService.updateReview(eq(reviewId), eq(authorId), any())) + .willThrow(new ReviewNotFoundException(reviewId)); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReviewUpdateRequest("수정 내용", 3.0)))) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ReviewNotFoundException")); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 수정 시 403을 반환한다") + void updateReview_forbidden_returns403() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + given(reviewService.updateReview(eq(reviewId), eq(authorId), any())) + .willThrow(new ReviewForbiddenException()); + + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReviewUpdateRequest("수정 내용", 3.0)))) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("ReviewForbiddenException")); + } + + @Test + @DisplayName("수정 요청에서 평점이 5.0 초과이면 400을 반환한다") + void updateReview_ratingOver5_returns400() throws Exception { + // when & then + mockMvc.perform(patch("/api/reviews/{reviewId}", UUID.randomUUID()) + .with(authentication(authOf(UUID.randomUUID()))) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(new ReviewUpdateRequest("내용", 5.1)))) + .andExpect(status().isBadRequest()); + } + + // ===== DELETE /api/reviews/{reviewId} ===== + + @Test + @DisplayName("리뷰 삭제에 성공하면 204를 반환한다") + void deleteReview_success() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf())) + .andExpect(status().isNoContent()); + + verify(reviewService).deleteReview(eq(reviewId), eq(authorId)); + } + + @Test + @DisplayName("인증 없이 리뷰 삭제하면 401을 반환한다") + void deleteReview_unauthenticated_returns401() throws Exception { + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", UUID.randomUUID()) + .with(csrf())) + .andExpect(status().isUnauthorized()); + + verify(reviewService, never()).deleteReview(any(), any()); + } + + @Test + @DisplayName("존재하지 않는 리뷰 삭제 시 404를 반환한다") + void deleteReview_notFound_returns404() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + willThrow(new ReviewNotFoundException(reviewId)) + .given(reviewService).deleteReview(eq(reviewId), eq(authorId)); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf())) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.exceptionType").value("ReviewNotFoundException")); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 삭제 시 403을 반환한다") + void deleteReview_forbidden_returns403() throws Exception { + // given + UUID authorId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + + willThrow(new ReviewForbiddenException()) + .given(reviewService).deleteReview(eq(reviewId), eq(authorId)); + + // when & then + mockMvc.perform(delete("/api/reviews/{reviewId}", reviewId) + .with(authentication(authOf(authorId))) + .with(csrf())) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.exceptionType").value("ReviewForbiddenException")); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java b/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java new file mode 100644 index 00000000..c2d7c855 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java @@ -0,0 +1,319 @@ +package com.codeit.team5.mopl.review.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.codeit.team5.mopl.config.JpaAuditingConfig; +import com.codeit.team5.mopl.TestcontainersConfiguration; +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.entity.ContentType; +import com.codeit.team5.mopl.global.support.config.QueryDslTestConfig; +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.user.entity.User; +import jakarta.persistence.EntityManager; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Comparator; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase.Replace; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; +import org.springframework.test.context.ActiveProfiles; + +@DataJpaTest +@ActiveProfiles("test") +@AutoConfigureTestDatabase(replace = Replace.NONE) +@Import({JpaAuditingConfig.class, TestcontainersConfiguration.class, QueryDslTestConfig.class}) +class ReviewRepositoryTest { + + @Autowired + private ReviewRepository reviewRepository; + + @Autowired + private EntityManager entityManager; + + private User persistUser(String email) { + User user = User.create(email, "password", "테스터"); + entityManager.persist(user); + entityManager.flush(); + return user; + } + + private Content persistContent(String title) { + Content content = Content.createByAdmin(ContentType.MOVIE, title, null); + entityManager.persist(content); + entityManager.flush(); + return content; + } + + private Review persistReview(Content content, User author, String text, double rating) { + Review review = Review.of(content, author, text, rating); + reviewRepository.save(review); + entityManager.flush(); + return review; + } + + private void setCreatedAt(java.util.UUID reviewId, Instant createdAt) { + entityManager.createNativeQuery( + "UPDATE reviews SET created_at = :ts WHERE id = :id") + .setParameter("ts", OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC)) + .setParameter("id", reviewId) + .executeUpdate(); + } + + @Test + @DisplayName("리뷰 저장에 성공하고 생성 시각이 자동으로 기록된다") + void save_success() { + // given + User author = persistUser("save@example.com"); + Content content = persistContent("영화1"); + + // when + Review saved = persistReview(content, author, "재밌어요", 4.5); + entityManager.clear(); + + // then + Review found = reviewRepository.findById(saved.getId()).orElseThrow(); + assertThat(found.getContentId()).isEqualTo(content.getId()); + assertThat(found.getAuthorId()).isEqualTo(author.getId()); + assertThat(found.getText()).isEqualTo("재밌어요"); + assertThat(found.getRating()).isEqualTo(4.5); + assertThat(found.getCreatedAt()).isNotNull(); + } + + @Test + @DisplayName("동일 콘텐츠에 동일 작성자가 두 번 저장하면 예외가 발생한다") + void save_duplicateReview_throwsException() { + // given + User author = persistUser("dup@example.com"); + Content content = persistContent("영화2"); + reviewRepository.saveAndFlush(Review.of(content, author, "첫 리뷰", 4.0)); + + // when & then + assertThatThrownBy(() -> + reviewRepository.saveAndFlush(Review.of(content, author, "중복 리뷰", 3.0))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + } + + @Test + @DisplayName("existsByContent_IdAndAuthor_Id: 리뷰가 존재하면 true를 반환한다") + void existsByContent_IdAndAuthor_Id_exists() { + // given + User author = persistUser("exists@example.com"); + Content content = persistContent("영화3"); + persistReview(content, author, "리뷰", 4.0); + entityManager.clear(); + + // when & then + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isTrue(); + } + + @Test + @DisplayName("existsByContent_IdAndAuthor_Id: 리뷰가 없으면 false를 반환한다") + void existsByContent_IdAndAuthor_Id_notExists() { + // given + User author = persistUser("notexists@example.com"); + Content content = persistContent("영화4"); + + // when & then + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isFalse(); + } + + @Test + @DisplayName("countByContent_Id: 콘텐츠의 리뷰 수를 정확히 반환한다") + void countByContent_Id_success() { + // given + User author1 = persistUser("count1@example.com"); + User author2 = persistUser("count2@example.com"); + Content content = persistContent("영화5"); + Content otherContent = persistContent("영화6"); + + persistReview(content, author1, "리뷰1", 4.0); + persistReview(content, author2, "리뷰2", 3.5); + persistReview(otherContent, author1, "다른 콘텐츠 리뷰", 5.0); + entityManager.clear(); + + // when & then + assertThat(reviewRepository.countByContent_Id(content.getId())).isEqualTo(2); + } + + @Test + @DisplayName("createdAt 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") + void findPageByContentIdSortByCreatedAt_desc_pagination() { + // given + User author1 = persistUser("page-desc1@example.com"); + User author2 = persistUser("page-desc2@example.com"); + User author3 = persistUser("page-desc3@example.com"); + User author4 = persistUser("page-desc4@example.com"); + User author5 = persistUser("page-desc5@example.com"); + Content content = persistContent("영화7"); + Content otherContent = persistContent("영화8"); + + Review r1 = persistReview(content, author1, "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2, "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3, "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + Review r4 = persistReview(content, author4, "r4", 2.0); + setCreatedAt(r4.getId(), Instant.parse("2026-01-01T00:04:00Z")); + Review r5 = persistReview(content, author5, "r5", 1.5); + setCreatedAt(r5.getId(), Instant.parse("2026-01-01T00:05:00Z")); + persistReview(otherContent, author1, "other", 4.0); + entityManager.flush(); + entityManager.clear(); + + // when + List firstPage = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), null, null, Limit.of(2), Sort.Direction.DESC); + Review cursor = firstPage.get(firstPage.size() - 1); + List secondPage = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), cursor.getCreatedAt(), cursor.getId(), Limit.of(2), Sort.Direction.DESC); + + // then + assertThat(firstPage).hasSize(2); + assertThat(secondPage).hasSize(2); + assertThat(secondPage).extracting(Review::getId) + .doesNotContainAnyElementsOf(firstPage.stream().map(Review::getId).toList()); + } + + @Test + @DisplayName("createdAt 내림차순 조회는 최신 리뷰가 먼저 반환된다") + void findPageByContentIdSortByCreatedAt_desc_ordering() { + // given + User author1 = persistUser("order-desc1@example.com"); + User author2 = persistUser("order-desc2@example.com"); + User author3 = persistUser("order-desc3@example.com"); + Content content = persistContent("영화9"); + + Review r1 = persistReview(content, author1, "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2, "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3, "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), null, null, Limit.of(10), Sort.Direction.DESC); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getCreatedAt).reversed()); + } + + @Test + @DisplayName("createdAt 오름차순 조회는 오래된 리뷰가 먼저 반환된다") + void findPageByContentIdSortByCreatedAt_asc_ordering() { + // given + User author1 = persistUser("order-asc1@example.com"); + User author2 = persistUser("order-asc2@example.com"); + User author3 = persistUser("order-asc3@example.com"); + Content content = persistContent("영화10"); + + Review r1 = persistReview(content, author1, "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2, "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3, "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), null, null, Limit.of(10), Sort.Direction.ASC); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getCreatedAt)); + } + + @Test + @DisplayName("rating 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") + void findPageByContentIdSortByRating_desc_pagination() { + // given + User author1 = persistUser("rating-page1@example.com"); + User author2 = persistUser("rating-page2@example.com"); + User author3 = persistUser("rating-page3@example.com"); + User author4 = persistUser("rating-page4@example.com"); + Content content = persistContent("영화11"); + + persistReview(content, author1, "r1", 5.0); + persistReview(content, author2, "r2", 4.0); + persistReview(content, author3, "r3", 3.0); + persistReview(content, author4, "r4", 2.0); + entityManager.flush(); + entityManager.clear(); + + // when + List firstPage = reviewRepository.findPageByContentIdSortByRating( + content.getId(), null, null, Limit.of(2), Sort.Direction.DESC); + Review cursor = firstPage.get(firstPage.size() - 1); + List secondPage = reviewRepository.findPageByContentIdSortByRating( + content.getId(), cursor.getRating(), cursor.getId(), Limit.of(2), Sort.Direction.DESC); + + // then + assertThat(firstPage).hasSize(2); + assertThat(secondPage).hasSize(2); + assertThat(secondPage).extracting(Review::getId) + .doesNotContainAnyElementsOf(firstPage.stream().map(Review::getId).toList()); + } + + @Test + @DisplayName("rating 내림차순 조회는 높은 평점이 먼저 반환된다") + void findPageByContentIdSortByRating_desc_ordering() { + // given + User author1 = persistUser("rating-order1@example.com"); + User author2 = persistUser("rating-order2@example.com"); + User author3 = persistUser("rating-order3@example.com"); + Content content = persistContent("영화12"); + + persistReview(content, author1, "r1", 3.0); + persistReview(content, author2, "r2", 5.0); + persistReview(content, author3, "r3", 1.0); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentIdSortByRating( + content.getId(), null, null, Limit.of(10), Sort.Direction.DESC); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getRating).reversed()); + } + + @Test + @DisplayName("rating 오름차순 조회는 낮은 평점이 먼저 반환된다") + void findPageByContentIdSortByRating_asc_ordering() { + // given + User author1 = persistUser("rating-asc1@example.com"); + User author2 = persistUser("rating-asc2@example.com"); + User author3 = persistUser("rating-asc3@example.com"); + Content content = persistContent("영화13"); + + persistReview(content, author1, "r1", 3.0); + persistReview(content, author2, "r2", 5.0); + persistReview(content, author3, "r3", 1.0); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentIdSortByRating( + content.getId(), null, null, Limit.of(10), Sort.Direction.ASC); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getRating)); + } +} diff --git a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java new file mode 100644 index 00000000..13d05641 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -0,0 +1,340 @@ +package com.codeit.team5.mopl.review.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +import com.codeit.team5.mopl.content.entity.Content; +import com.codeit.team5.mopl.content.exception.ContentNotFoundException; +import com.codeit.team5.mopl.content.repository.ContentRepository; +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.review.contant.ReviewSortBy; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewGetRequest; +import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.entity.Review; +import com.codeit.team5.mopl.review.exception.ReviewAlreadyExistsException; +import com.codeit.team5.mopl.review.exception.ReviewForbiddenException; +import com.codeit.team5.mopl.review.exception.ReviewNotFoundException; +import com.codeit.team5.mopl.review.mapper.ReviewMapper; +import com.codeit.team5.mopl.review.repository.ReviewRepository; +import com.codeit.team5.mopl.user.entity.User; +import com.codeit.team5.mopl.user.exception.UserNotFoundException; +import com.codeit.team5.mopl.user.repository.UserRepository; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +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 org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; + +@ExtendWith(MockitoExtension.class) +class ReviewServiceTest { + + @Mock + private ReviewRepository reviewRepository; + + @Mock + private UserRepository userRepository; + + @Mock + private ContentRepository contentRepository; + + @Mock + private ReviewMapper reviewMapper; + + @InjectMocks + private ReviewService reviewService; + + @Test + @DisplayName("다음 페이지가 있으면 limit만큼 자르고 createdAt 기준 nextCursor를 채운다") + void getReviews_hasNext_createdAtCursor() { + // given + UUID contentId = UUID.randomUUID(); + Instant lastCreatedAt = Instant.parse("2026-01-01T00:00:00Z"); + UUID lastId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + Review r1 = mock(Review.class); + Review r2 = mock(Review.class); + given(r1.getCreatedAt()).willReturn(lastCreatedAt); + given(r1.getId()).willReturn(lastId); + + ReviewGetRequest request = new ReviewGetRequest(contentId, null, null, 2, Sort.Direction.DESC, ReviewSortBy.CREATED_AT); + given(reviewRepository.findPageByContentIdSortByCreatedAt(contentId, null, null, Limit.of(3), Sort.Direction.DESC)) + .willReturn(List.of(r0, r1, r2)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(5L); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews(request); + + // then + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(lastCreatedAt.toString()); + assertThat(result.nextIdAfter()).isEqualTo(lastId.toString()); + assertThat(result.totalCount()).isEqualTo(5L); + } + + @Test + @DisplayName("다음 페이지가 있으면 rating 기준 nextCursor를 채운다") + void getReviews_hasNext_ratingCursor() { + // given + UUID contentId = UUID.randomUUID(); + double lastRating = 4.5; + UUID lastId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + Review r1 = mock(Review.class); + Review r2 = mock(Review.class); + given(r1.getRating()).willReturn(lastRating); + given(r1.getId()).willReturn(lastId); + + ReviewGetRequest request = new ReviewGetRequest(contentId, null, null, 2, Sort.Direction.DESC, ReviewSortBy.RATING); + given(reviewRepository.findPageByContentIdSortByRating(contentId, null, null, Limit.of(3), Sort.Direction.DESC)) + .willReturn(List.of(r0, r1, r2)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(5L); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews(request); + + // then + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(String.valueOf(lastRating)); + assertThat(result.nextIdAfter()).isEqualTo(lastId.toString()); + } + + @Test + @DisplayName("마지막 페이지면 hasNext가 false이고 nextCursor가 null이다") + void getReviews_lastPage() { + // given + UUID contentId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + ReviewGetRequest request = new ReviewGetRequest(contentId, null, null, 2, Sort.Direction.DESC, ReviewSortBy.CREATED_AT); + given(reviewRepository.findPageByContentIdSortByCreatedAt(contentId, null, null, Limit.of(3), Sort.Direction.DESC)) + .willReturn(List.of(r0)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(1L); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews(request); + + // then + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + assertThat(result.nextIdAfter()).isNull(); + } + + @Test + @DisplayName("sortDirection이 ASCENDING이면 ASC로 리포지토리를 호출한다") + void getReviews_ascending() { + // given + UUID contentId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + ReviewGetRequest request = new ReviewGetRequest(contentId, null, null, 2, Sort.Direction.ASC, ReviewSortBy.CREATED_AT); + given(reviewRepository.findPageByContentIdSortByCreatedAt(contentId, null, null, Limit.of(3), Sort.Direction.ASC)) + .willReturn(List.of(r0)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(1L); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews(request); + + // then + assertThat(result.hasNext()).isFalse(); + } + + @Test + @DisplayName("리뷰 생성에 성공한다") + void ofReview_success() { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); + + Content content = mock(Content.class); + User user = mock(User.class); + Review saved = mock(Review.class); + ReviewResponse response = mock(ReviewResponse.class); + + given(contentRepository.findById(contentId)).willReturn(Optional.of(content)); + given(userRepository.findById(authorId)).willReturn(Optional.of(user)); + given(reviewRepository.existsByContent_IdAndAuthor_Id(contentId, authorId)).willReturn(false); + given(reviewRepository.save(any())).willReturn(saved); + given(saved.getId()).willReturn(UUID.randomUUID()); + given(saved.getContentId()).willReturn(contentId); + given(reviewMapper.toDto(saved)).willReturn(response); + + // when + ReviewResponse result = reviewService.createReview(authorId, request); + + // then + assertThat(result).isEqualTo(response); + verify(reviewRepository).save(any()); + } + + @Test + @DisplayName("콘텐츠가 존재하지 않으면 리뷰 생성 시 예외가 발생한다") + void ofReview_contentNotFound_exception() { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); + + given(contentRepository.findById(contentId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> reviewService.createReview(authorId, request)) + .isInstanceOf(ContentNotFoundException.class); + } + + @Test + @DisplayName("이미 리뷰를 작성했으면 예외가 발생한다") + void ofReview_alreadyExists_exception() { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); + + given(contentRepository.findById(contentId)).willReturn(Optional.of(mock(Content.class))); + given(userRepository.findById(authorId)).willReturn(Optional.of(mock(User.class))); + given(reviewRepository.existsByContent_IdAndAuthor_Id(contentId, authorId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> reviewService.createReview(authorId, request)) + .isInstanceOf(ReviewAlreadyExistsException.class); + } + + @Test + @DisplayName("존재하지 않는 유저가 리뷰 생성 시 예외가 발생한다") + void ofReview_userNotFound_exception() { + // given + UUID authorId = UUID.randomUUID(); + UUID contentId = UUID.randomUUID(); + ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); + + given(contentRepository.findById(contentId)).willReturn(Optional.of(mock(Content.class))); + given(userRepository.findById(authorId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> reviewService.createReview(authorId, request)) + .isInstanceOf(UserNotFoundException.class); + } + + @Test + @DisplayName("리뷰 수정에 성공한다") + void updateReview_success() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + ReviewUpdateRequest request = new ReviewUpdateRequest("수정된 내용", 3.0); + + Review review = mock(Review.class); + ReviewResponse response = mock(ReviewResponse.class); + + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(authorId); + given(reviewMapper.toDto(review)).willReturn(response); + + // when + ReviewResponse result = reviewService.updateReview(reviewId, authorId, request); + + // then + assertThat(result).isEqualTo(response); + verify(review).update("수정된 내용", 3.0); + } + + @Test + @DisplayName("존재하지 않는 리뷰 수정 시 예외가 발생한다") + void updateReview_notFound_exception() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> reviewService.updateReview( + reviewId, authorId, new ReviewUpdateRequest(null, null))) + .isInstanceOf(ReviewNotFoundException.class); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 수정 시 예외가 발생한다") + void updateReview_forbidden_exception() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID otherId = UUID.randomUUID(); + + Review review = mock(Review.class); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(otherId); + + // when & then + assertThatThrownBy(() -> reviewService.updateReview( + reviewId, authorId, new ReviewUpdateRequest("수정내용", 4.0))) + .isInstanceOf(ReviewForbiddenException.class); + } + + @Test + @DisplayName("리뷰 삭제에 성공한다") + void deleteReview_success() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + Review review = mock(Review.class); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(authorId); + + // when + reviewService.deleteReview(reviewId, authorId); + + // then + verify(reviewRepository).delete(review); + } + + @Test + @DisplayName("존재하지 않는 리뷰 삭제 시 예외가 발생한다") + void deleteReview_notFound_exception() { + // given + UUID reviewId = UUID.randomUUID(); + + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, UUID.randomUUID())) + .isInstanceOf(ReviewNotFoundException.class); + } + + @Test + @DisplayName("본인의 리뷰가 아니면 삭제 시 예외가 발생한다") + void deleteReview_forbidden_exception() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + UUID otherId = UUID.randomUUID(); + + Review review = mock(Review.class); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(otherId); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, authorId)) + .isInstanceOf(ReviewForbiddenException.class); + } +}