From 4e4d3c676662272df23e1889057984c8dfc1e4e2 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 11:01:44 +0900 Subject: [PATCH 01/32] =?UTF-8?q?Feat:=20=EB=A6=AC=EB=B7=B0=20=EC=97=94?= =?UTF-8?q?=ED=8B=B0=ED=8B=B0=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/entity/Review.java | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/review/entity/Review.java 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..20bc7843 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -0,0 +1,52 @@ +package com.codeit.team5.mopl.review.entity; + +import com.codeit.team5.mopl.global.entity.BaseEntity; +import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +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", "author_id"})) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Review extends BaseUpdatableEntity { + + @Column(name = "content_id", nullable = false, columnDefinition = "uuid") + private UUID contentId; + + @Column(name = "author_id", nullable = false, columnDefinition = "uuid") + private UUID authorId; + + @Column(columnDefinition = "TEXT", nullable = false) + private String text; + + @Column(nullable = false) + private Double rating; + + public static Review create(UUID contentId, UUID authorId, String text, Double rating) { + return new Review(contentId, authorId, text, rating); + } + + private Review(UUID contentId, UUID authorId, String text, Double rating) { + this.contentId = contentId; + this.authorId = authorId; + this.text = text; + this.rating = rating; + } + + public void update(String text, Double rating) { + if (text != null) { + this.text = text; + } + if (rating != null) { + this.rating = rating; + } + } +} From 81f096dacf7f8fe71fefb4c92e163c80b6270d4b Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 11:07:00 +0900 Subject: [PATCH 02/32] =?UTF-8?q?Feat:=20Api=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EB=A5=BC=20swagger=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EB=AA=85=EC=84=B8=20=EB=B0=8F=20=EC=BB=A8=ED=8A=B8?= =?UTF-8?q?=EB=A1=A4=EB=9F=AC=20=EA=B5=AC=ED=98=84=20-=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=9A=94?= =?UTF-8?q?=EC=B2=AD=20DTO=20=EC=9E=91=EC=84=B1=20-=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20-=20ReviewApi=EC=99=80=20Controller=20=EB=8B=A8?= =?UTF-8?q?=EC=97=90=EC=84=9C=EB=8A=94=20=EC=9C=A0=EC=A0=80=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20MoplPrincipal?= =?UTF-8?q?=EB=A1=9C=20=EB=B0=9B=EC=95=84=EC=98=A4=EB=8A=94=EA=B2=83?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/controller/ReviewController.java | 97 ++++++++++++++++ .../mopl/review/controller/api/ReviewApi.java | 106 ++++++++++++++++++ .../dto/request/ReviewCreateRequest.java | 26 +++++ .../dto/request/ReviewUpdateRequest.java | 17 +++ .../review/dto/response/ReviewResponse.java | 13 +++ 5 files changed, 259 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/controller/api/ReviewApi.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewCreateRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewUpdateRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/dto/response/ReviewResponse.java 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..c583bb39 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java @@ -0,0 +1,97 @@ +package com.codeit.team5.mopl.review.controller; + +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; +import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; +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.ReviewUpdateRequest; +import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.review.service.ReviewService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.RequestParam; +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( + @RequestParam UUID contentId, + @RequestParam(required = false) String cursor, + @RequestParam(required = false) UUID idAfter, + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit, + @RequestParam(defaultValue = "DESCENDING") String sortDirection, + @RequestParam(defaultValue = "createdAt") String sortBy) { + + log.info("리뷰 목록 조회: GET /api/reviews, contentId={}", contentId); + + CursorResponse response = reviewService.getReviews( + contentId, cursor, idAfter, limit, sortDirection, sortBy); + + 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 MoplUserDetails userDetails, + @PathVariable UUID reviewId) { + + log.info("리뷰 삭제: DELETE /api/reviews/{}, authorId={}", reviewId, userDetails.getId()); + + reviewService.deleteReview(reviewId, userDetails.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..eddc3045 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/controller/api/ReviewApi.java @@ -0,0 +1,106 @@ +package com.codeit.team5.mopl.review.controller.api; + +import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; +import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; +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.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 jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import java.util.UUID; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; + +@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( + @Parameter(description = "콘텐츠 ID", required = true) + @RequestParam UUID contentId, + @Parameter(description = "커서") + @RequestParam(required = false) String cursor, + @Parameter(description = "보조 커서") + @RequestParam(required = false) UUID idAfter, + @Parameter(description = "한 번에 가져올 개수") + @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit, + @Parameter(description = "정렬 방향 (ASCENDING, DESCENDING)") + @RequestParam(defaultValue = "DESCENDING") String sortDirection, + @Parameter(description = "정렬 기준 (createdAt, rating)") + @RequestParam(defaultValue = "createdAt") String sortBy); + + @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) MoplUserDetails userDetails, + @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/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 +) { +} From 281f0e82702284bbad86d2feba5ced136728e484 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 15:06:07 +0900 Subject: [PATCH 03/32] =?UTF-8?q?Feat:=20Service,=20Repository=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=B0=8F=20=EC=98=88=EC=99=B8=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=20-=20ReviewResponseDto=EA=B0=80=20=20User=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4(=EB=A6=AC=EB=B7=B0=20=EC=9E=91=EC=84=B1=EC=9E=90)?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A7=80=EA=B3=A0=20=EC=9E=88=EC=96=B4?= =?UTF-8?q?=EC=84=9C=20Dto=EB=A1=9C=20=EB=A7=A4=ED=95=91=20=EC=8B=9C=20Use?= =?UTF-8?q?r=EC=9D=98=20=EC=A0=95=EB=B3=B4=EA=B0=80=20=ED=95=84=EC=9A=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InvalidReviewSortByException.java | 11 ++ .../ReviewAlreadyExistsException.java | 11 ++ .../exception/ReviewForbiddenException.java | 11 ++ .../exception/ReviewNotFoundException.java | 12 ++ .../mopl/review/mapper/ReviewMapper.java | 28 ++++ .../review/repository/ReviewRepository.java | 13 ++ .../querydsl/ReviewQueryRepository.java | 12 ++ .../querydsl/ReviewQueryRepositoryImpl.java | 85 +++++++++++ .../mopl/review/service/ReviewService.java | 138 ++++++++++++++++++ 9 files changed, 321 insertions(+) create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/InvalidReviewSortByException.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/ReviewAlreadyExistsException.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/ReviewForbiddenException.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/ReviewNotFoundException.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepository.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepositoryImpl.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java 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/mapper/ReviewMapper.java b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java new file mode 100644 index 00000000..0214e719 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java @@ -0,0 +1,28 @@ +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.dto.response.UserSummaryResponse; +import com.codeit.team5.mopl.user.entity.User; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; +import org.mapstruct.ReportingPolicy; + +@Mapper( + componentModel = "spring", + unmappedTargetPolicy = ReportingPolicy.ERROR +) +public interface ReviewMapper { + + @Mapping(target = "id", source = "review.id") + @Mapping(target = "contentId", source = "review.contentId") + @Mapping(target = "text", source = "review.text") + @Mapping(target = "rating", source = "review.rating") + @Mapping(target = "author", source = "user") + ReviewResponse toDto(Review review, User user); + + + @Mapping(target = "userId", source = "id") + @Mapping(target = "profileImageUrl", source = "profileImage.url") + UserSummaryResponse toAuthor(User user); +} 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..1c1727a8 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java @@ -0,0 +1,13 @@ +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.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { + + boolean existsByContentIdAndAuthorId(UUID contentId, UUID authorId); + + long countByContentId(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..3a4102dd --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepository.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.review.repository.querydsl; + +import com.codeit.team5.mopl.review.entity.Review; +import java.util.List; +import java.util.UUID; +import org.springframework.data.domain.Limit; + +public interface ReviewQueryRepository { + + List findPageByContentId(UUID contentId, String cursor, UUID idAfter, + Limit limit, String sortBy, boolean ascending); +} 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..09500fb4 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/repository/querydsl/ReviewQueryRepositoryImpl.java @@ -0,0 +1,85 @@ +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.time.format.DateTimeParseException; +import java.util.List; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { + + private static final String SORT_BY_CREATED_AT = "createdAt"; + private static final String SORT_BY_RATING = "rating"; + + private final JPAQueryFactory queryFactory; + + @Override + public List findPageByContentId(UUID contentId, String cursor, UUID idAfter, + Limit limit, String sortBy, boolean ascending) { + + QReview r = QReview.review; + + BooleanExpression cursorCondition = buildCursorCondition(r, cursor, idAfter, sortBy, ascending); + OrderSpecifier[] orderSpecifiers = buildOrderSpecifiers(r, sortBy, ascending); + + return queryFactory.selectFrom(r) + .where(r.contentId.eq(contentId), cursorCondition) + .orderBy(orderSpecifiers) + .limit(limit.max()) + .fetch(); + } + + // 커서 페이지네이션을 위한 BooleanExpression 생성 + private BooleanExpression buildCursorCondition(QReview r, String cursor, UUID idAfter, + String sortBy, boolean ascending) { + + // 첫 페이지면 커서가 없으니 조건 없음 (전체 조회) + if (cursor == null) { + return null; + } + + if (SORT_BY_RATING.equals(sortBy)) { // 점수 기준 정렬 + double cursorRating; + try { + cursorRating = Double.parseDouble(cursor); // string 커서를 double 형식으로 파싱 + } catch (NumberFormatException e) { + throw new IllegalArgumentException("잘못된 커서 형식입니다: " + cursor); + } + return ascending + // 커서 평점보다 평점이 큰 리뷰, 두개가 같으면 보조 커서를 기준으로 정렬 + ? r.rating.gt(cursorRating).or(r.rating.eq(cursorRating).and(r.id.gt(idAfter))) + : r.rating.lt(cursorRating).or(r.rating.eq(cursorRating).and(r.id.lt(idAfter))); + } + + Instant cursorInstant; // createdAt 기준 정렬 + try { + cursorInstant = Instant.parse(cursor); // string 커서 -> Instant 파싱 + } catch (DateTimeParseException e) { + throw new IllegalArgumentException("잘못된 커서 형식입니다: " + cursor); + } + return ascending + ? r.createdAt.gt(cursorInstant).or(r.createdAt.eq(cursorInstant).and(r.id.gt(idAfter))) + : r.createdAt.lt(cursorInstant).or(r.createdAt.eq(cursorInstant).and(r.id.lt(idAfter))); + } + + // orderBy를 적용하기 위해 OrderSpecifier 생성 + private OrderSpecifier[] buildOrderSpecifiers(QReview r, String sortBy, boolean ascending) { + if (SORT_BY_RATING.equals(sortBy)) { // 평점 순 정렬 + return ascending + ? new OrderSpecifier[] {r.rating.asc(), r.id.asc()} + : new OrderSpecifier[] {r.rating.desc(), r.id.desc()}; + } + return ascending // createdAt 순 정렬 + ? new OrderSpecifier[] {r.createdAt.asc(), r.id.asc()} + : new OrderSpecifier[] {r.createdAt.desc(), r.id.desc()}; + } +} 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..304fee4a --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -0,0 +1,138 @@ +package com.codeit.team5.mopl.review.service; + +import com.codeit.team5.mopl.global.dto.CursorResponse; +import com.codeit.team5.mopl.global.exception.InvalidSortDirectionException; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +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.InvalidReviewSortByException; +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.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Limit; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private static final String SORT_BY_CREATED_AT = "createdAt"; + private static final String SORT_BY_RATING = "rating"; + private static final String SORT_ASCENDING = "ASCENDING"; + private static final String SORT_DESCENDING = "DESCENDING"; + + private final ReviewRepository reviewRepository; + private final UserRepository userRepository; + private final ReviewMapper reviewMapper; + + // 리뷰 목록 조회 + @Transactional + public CursorResponse getReviews( + UUID contentId, String cursor, UUID idAfter, int limit, + String sortDirection, String sortBy) { + + boolean ascending = resolveAscending(sortBy, sortDirection); + Limit fetchLimit = Limit.of(limit + 1); // LIMIT + 1 만큼 fetch 하여 다음 페이지 여부를 확인하기 위함 + + List rows = reviewRepository.findPageByContentId( + contentId, cursor, idAfter, fetchLimit, sortBy, ascending); + + + boolean hasNext = rows.size() > limit; // 다음 페이지 여부 + // 다음 페이지가 있으면 row에서 limit 만큼 fetch + // 다음 페이지 없으면 row 다 fetch + 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); // 페이지의 마지막 item + nextCursor = SORT_BY_RATING.equals(sortBy) + ? last.getRating().toString() // 평점 기준일 때 마지막 item의 평점을 cursor로 + : last.getCreatedAt().toString(); // createdAt + nextIdAfter = last.getId().toString(); // 보조 커서는 id + } + + // 총 개수 + long totalCount = reviewRepository.countByContentId(contentId); + + // 페이지의 authorId를 한 번에 모아서 배치 조회 (N+1 방지) + Map userMap = userRepository.findAllById( + page.stream().map(Review::getAuthorId).toList() + ).stream() + .collect(Collectors.toMap(User::getId, u -> u)); + + List data = page.stream() + .map(review -> reviewMapper.toDto(review, userMap.get(review.getAuthorId()))) + .toList(); + + return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, sortBy, sortDirection); + } + + // 리뷰 생성 + @Transactional + public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { + // 해당 유저의 리뷰가 이미 존재하면 예외 던지기 + if (reviewRepository.existsByContentIdAndAuthorId(request.contentId(), authorId)) { + throw new ReviewAlreadyExistsException(); + } + Review review = Review.create(request.contentId(), authorId, request.text(), request.rating()); + Review saved = reviewRepository.save(review); + User user = userRepository.findById(saved.getAuthorId()).orElseThrow(() -> new UserNotFoundException(authorId)); + + return reviewMapper.toDto(saved, user); + } + + // 리뷰 수정 + @Transactional + public ReviewResponse updateReview(UUID reviewId, UUID authorId, ReviewUpdateRequest request) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + if (!review.getAuthorId().equals(authorId)) { + throw new ReviewForbiddenException(); + } + review.update(request.text(), request.rating()); + User author = userRepository.findById(authorId) + .orElseThrow(() -> new UserNotFoundException(authorId)); + return reviewMapper.toDto(review, author); + } + + // 리뷰 삭제 + @Transactional + public void deleteReview(UUID reviewId, UUID authorId) { + Review review = reviewRepository.findById(reviewId) + .orElseThrow(() -> new ReviewNotFoundException(reviewId)); + if (!review.getAuthorId().equals(authorId)) { + throw new ReviewForbiddenException(); + } + reviewRepository.delete(review); + } + + // SortBy, SortDirection을 검증하고 ASC/DESC 여부를 정하는 헬퍼 메서드 + private boolean resolveAscending(String sortBy, String sortDirection) { + if (!SORT_BY_CREATED_AT.equals(sortBy) && !SORT_BY_RATING.equals(sortBy)) { + throw new InvalidReviewSortByException(sortBy); // 평점 순, createdAt 순도 아닌 이상한게 뭐가 들어왔어 + } + if (SORT_ASCENDING.equalsIgnoreCase(sortDirection)) { + return true; + } + if (SORT_DESCENDING.equalsIgnoreCase(sortDirection)) { + return false; + } + throw new InvalidSortDirectionException(sortDirection); + } +} From f15b09dff0dee0a57187a67384b4d32e123dc57f Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 15:10:45 +0900 Subject: [PATCH 04/32] =?UTF-8?q?Fix:=20=ED=85=8C=EC=9D=B4=EB=B8=94=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EB=AA=85=EA=B3=BC=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=BB=AC=EB=9F=BC=EB=AA=85=EC=9D=B4=20=EB=8B=AC?= =?UTF-8?q?=EB=9D=BC=20=EC=83=9D=EA=B8=B0=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/codeit/team5/mopl/review/entity/Review.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 20bc7843..90171f72 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -13,7 +13,7 @@ @Entity @Table(name = "reviews", - uniqueConstraints = @UniqueConstraint(columnNames = {"content_id", "author_id"})) + uniqueConstraints = @UniqueConstraint(columnNames = {"content_id", "user_id"})) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Review extends BaseUpdatableEntity { @@ -21,7 +21,7 @@ public class Review extends BaseUpdatableEntity { @Column(name = "content_id", nullable = false, columnDefinition = "uuid") private UUID contentId; - @Column(name = "author_id", nullable = false, columnDefinition = "uuid") + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") private UUID authorId; @Column(columnDefinition = "TEXT", nullable = false) From 469305d6bd06c2b076dbd4e0cbcf6dfdf471ee09 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 16:23:38 +0900 Subject: [PATCH 05/32] =?UTF-8?q?Fix:=20=EC=BD=94=EB=93=9C=EB=9E=98?= =?UTF-8?q?=EB=B9=97=20=ED=94=BC=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?-=20Service=20=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20create=20?= =?UTF-8?q?=EC=8B=9C=20request=EC=97=90=20=EB=8B=B4=EA=B8=B4=20content=20i?= =?UTF-8?q?d=EB=A5=BC=20=EA=B8=B0=EB=B0=98=EC=9C=BC=EB=A1=9C=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=BB=A8=ED=85=90=EC=B8=A0=EA=B0=80=20=EC=A1=B4?= =?UTF-8?q?=EC=9E=AC=ED=95=98=EB=8A=94=20=EC=A7=80=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?-=20Repository=20=EC=97=90=EC=84=9C=EB=8A=94=20cursor=EC=99=80?= =?UTF-8?q?=20idAfter=EA=B0=80=20=EB=91=98=EB=8B=A4=20=EB=84=90=EC=9D=B4?= =?UTF-8?q?=EA=B1=B0=EB=82=98=20=EB=91=98=20=EB=8B=A4=20=EC=A1=B4=EC=9E=AC?= =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B2=80=EC=A6=9D=20-=20Review?= =?UTF-8?q?=20=EC=97=94=ED=8B=B0=ED=8B=B0=EC=97=90=EC=84=9C=20rating=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=ED=94=84=EB=9D=BC=EC=9D=B4=EB=B9=97=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ReviewUpdateRequest.java | 2 ++ .../team5/mopl/review/entity/Review.java | 11 +++++++++-- .../exception/InvalidRatingException.java | 12 ++++++++++++ .../querydsl/ReviewQueryRepositoryImpl.java | 6 ++++++ .../mopl/review/service/ReviewService.java | 19 +++++++++++++++++-- 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java 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 index d6542059..ee4cfc90 100644 --- 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 @@ -3,10 +3,12 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; @Schema(name = "ReviewUpdateRequest", description = "리뷰 수정 요청") public record ReviewUpdateRequest( @Schema(description = "리뷰 내용") + @NotBlank(message = "리뷰 내용은 필수입니다.") String text, @Schema(description = "평점 (0.0 ~ 5.0)") 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 index 90171f72..2cc33660 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -2,6 +2,7 @@ import com.codeit.team5.mopl.global.entity.BaseEntity; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; +import com.codeit.team5.mopl.review.exception.InvalidRatingException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; @@ -31,6 +32,7 @@ public class Review extends BaseUpdatableEntity { private Double rating; public static Review create(UUID contentId, UUID authorId, String text, Double rating) { + validateRating(rating); return new Review(contentId, authorId, text, rating); } @@ -42,11 +44,16 @@ private Review(UUID contentId, UUID authorId, String text, Double rating) { } public void update(String text, Double rating) { + validateRating(rating); if (text != null) { this.text = text; } - if (rating != null) { - this.rating = rating; + this.rating = rating; + } + + private static void validateRating(Double rating){ + if(rating == null || rating < 0.0 || rating > 5.0){ + throw new InvalidRatingException(rating); } } } diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java new file mode 100644 index 00000000..ccaa86df --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java @@ -0,0 +1,12 @@ +package com.codeit.team5.mopl.review.exception; + +import com.codeit.team5.mopl.global.exception.BusinessException; +import org.springframework.http.HttpStatus; + +public class InvalidRatingException extends BusinessException { + + public InvalidRatingException(Double rating) { + + super(HttpStatus.BAD_REQUEST, "평점은 0.0 이상, 5.0 이하여야 합니다. rating: "+ rating); + } +} 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 index 09500fb4..1e8091c6 100644 --- 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 @@ -1,5 +1,6 @@ package com.codeit.team5.mopl.review.repository.querydsl; +import com.codeit.team5.mopl.notification.exception.CursorIdAfterNotTogetherException; import com.codeit.team5.mopl.review.entity.QReview; import com.codeit.team5.mopl.review.entity.Review; import com.querydsl.core.types.OrderSpecifier; @@ -42,6 +43,11 @@ public List findPageByContentId(UUID contentId, String cursor, UUID idAf private BooleanExpression buildCursorCondition(QReview r, String cursor, UUID idAfter, String sortBy, boolean ascending) { + // cursor와 idAfter를 한 세트로 받아오기 + if((cursor != null && idAfter == null) || (cursor == null && idAfter != null)) { + throw new CursorIdAfterNotTogetherException(); + } + // 첫 페이지면 커서가 없으니 조건 없음 (전체 조회) if (cursor == null) { return null; 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 index 304fee4a..54f31056 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -1,5 +1,7 @@ package com.codeit.team5.mopl.review.service; +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.global.exception.InvalidSortDirectionException; import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; @@ -20,6 +22,7 @@ import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -36,10 +39,11 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final UserRepository userRepository; + private final ContentRepository contentRepository; private final ReviewMapper reviewMapper; // 리뷰 목록 조회 - @Transactional + @Transactional(readOnly = true) public CursorResponse getReviews( UUID contentId, String cursor, UUID idAfter, int limit, String sortDirection, String sortBy) { @@ -86,12 +90,23 @@ public CursorResponse getReviews( // 리뷰 생성 @Transactional public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { + // 콘텐츠가 존재하는 지 검증 + if(!contentRepository.existsById(request.contentId())) + { + throw new ContentNotFoundException(request.contentId()); + } + // 해당 유저의 리뷰가 이미 존재하면 예외 던지기 if (reviewRepository.existsByContentIdAndAuthorId(request.contentId(), authorId)) { throw new ReviewAlreadyExistsException(); } Review review = Review.create(request.contentId(), authorId, request.text(), request.rating()); - Review saved = reviewRepository.save(review); + Review saved; + try{ + saved = reviewRepository.saveAndFlush(review); + } catch (DataIntegrityViolationException e){ + throw new ReviewAlreadyExistsException(); + } User user = userRepository.findById(saved.getAuthorId()).orElseThrow(() -> new UserNotFoundException(authorId)); return reviewMapper.toDto(saved, user); From 2d491de691089fa4c029292c37c4c4bcb2c2cf76 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 16:35:16 +0900 Subject: [PATCH 06/32] =?UTF-8?q?Fix:=20ReviewQueryRepositoryImpl=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=8D=98=EC=A7=80=EB=8D=98=20IllegalArgument..Exce?= =?UTF-8?q?ption=EC=9D=84=20BusinessException=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/querydsl/ReviewQueryRepositoryImpl.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 index 1e8091c6..0cc99920 100644 --- 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 @@ -1,6 +1,7 @@ package com.codeit.team5.mopl.review.repository.querydsl; import com.codeit.team5.mopl.notification.exception.CursorIdAfterNotTogetherException; +import com.codeit.team5.mopl.notification.exception.InvalidCursorException; import com.codeit.team5.mopl.review.entity.QReview; import com.codeit.team5.mopl.review.entity.Review; import com.querydsl.core.types.OrderSpecifier; @@ -58,7 +59,7 @@ private BooleanExpression buildCursorCondition(QReview r, String cursor, UUID id try { cursorRating = Double.parseDouble(cursor); // string 커서를 double 형식으로 파싱 } catch (NumberFormatException e) { - throw new IllegalArgumentException("잘못된 커서 형식입니다: " + cursor); + throw new InvalidCursorException(); } return ascending // 커서 평점보다 평점이 큰 리뷰, 두개가 같으면 보조 커서를 기준으로 정렬 @@ -70,7 +71,7 @@ private BooleanExpression buildCursorCondition(QReview r, String cursor, UUID id try { cursorInstant = Instant.parse(cursor); // string 커서 -> Instant 파싱 } catch (DateTimeParseException e) { - throw new IllegalArgumentException("잘못된 커서 형식입니다: " + cursor); + throw new InvalidCursorException(); } return ascending ? r.createdAt.gt(cursorInstant).or(r.createdAt.eq(cursorInstant).and(r.id.gt(idAfter))) From 49c62fca6b71195966da33b5ffc7e755a4698c64 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 16:37:15 +0900 Subject: [PATCH 07/32] =?UTF-8?q?Fix:=20=EB=A6=AC=EB=B7=B0=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=EC=9A=94=EC=B2=AD=20DTO=20=ED=8F=89=EC=A0=90=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=EC=97=90=20NotNull=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/dto/request/ReviewUpdateRequest.java | 2 ++ 1 file changed, 2 insertions(+) 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 index ee4cfc90..957e1a14 100644 --- 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 @@ -4,6 +4,7 @@ import jakarta.validation.constraints.DecimalMax; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; @Schema(name = "ReviewUpdateRequest", description = "리뷰 수정 요청") public record ReviewUpdateRequest( @@ -12,6 +13,7 @@ public record ReviewUpdateRequest( String text, @Schema(description = "평점 (0.0 ~ 5.0)") + @NotNull(message = "rating은 필수입니다.") @DecimalMin(value = "0.0", message = "평점은 0.0 이상이어야 합니다.") @DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다.") Double rating From fefed22a2d2689744286d26c0f2db9dbc0575419 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 16:38:58 +0900 Subject: [PATCH 08/32] =?UTF-8?q?Fix:=20Entity=EC=9D=98=20=ED=95=84?= =?UTF-8?q?=EC=9A=94=EC=97=86=EB=8A=94=20=EA=B2=80=EC=A6=9D=20=EB=A9=94?= =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/codeit/team5/mopl/review/entity/Review.java | 8 -------- 1 file changed, 8 deletions(-) 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 index 2cc33660..7abd394a 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -32,7 +32,6 @@ public class Review extends BaseUpdatableEntity { private Double rating; public static Review create(UUID contentId, UUID authorId, String text, Double rating) { - validateRating(rating); return new Review(contentId, authorId, text, rating); } @@ -44,16 +43,9 @@ private Review(UUID contentId, UUID authorId, String text, Double rating) { } public void update(String text, Double rating) { - validateRating(rating); if (text != null) { this.text = text; } this.rating = rating; } - - private static void validateRating(Double rating){ - if(rating == null || rating < 0.0 || rating > 5.0){ - throw new InvalidRatingException(rating); - } - } } From 682daca16f3eac3bd5486deba0a0d86c6096ee49 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 16:44:50 +0900 Subject: [PATCH 09/32] =?UTF-8?q?Feat:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/service/ReviewService.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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 index 54f31056..00295f68 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -22,11 +22,13 @@ import java.util.UUID; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -102,11 +104,14 @@ public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { } Review review = Review.create(request.contentId(), authorId, request.text(), request.rating()); Review saved; - try{ + try { saved = reviewRepository.saveAndFlush(review); - } catch (DataIntegrityViolationException e){ + } catch (DataIntegrityViolationException e) { + log.warn("리뷰 중복 저장 시도 (race condition): contentId={}, authorId={}", request.contentId(), authorId); throw new ReviewAlreadyExistsException(); } + log.info("리뷰 생성 완료: reviewId={}, contentId={}, authorId={}", saved.getId(), saved.getContentId(), authorId); + User user = userRepository.findById(saved.getAuthorId()).orElseThrow(() -> new UserNotFoundException(authorId)); return reviewMapper.toDto(saved, user); @@ -118,9 +123,11 @@ public ReviewResponse updateReview(UUID reviewId, UUID authorId, ReviewUpdateReq Review review = reviewRepository.findById(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); User author = userRepository.findById(authorId) .orElseThrow(() -> new UserNotFoundException(authorId)); return reviewMapper.toDto(review, author); @@ -132,9 +139,11 @@ public void deleteReview(UUID reviewId, UUID authorId) { Review review = reviewRepository.findById(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); } // SortBy, SortDirection을 검증하고 ASC/DESC 여부를 정하는 헬퍼 메서드 From c6a7e74b2c12f4bbed38155d3932d30a0a344d6b Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 17:17:11 +0900 Subject: [PATCH 10/32] =?UTF-8?q?Refactor:=20Refactor:=20Review=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20content=5Fid=EB=A5=BC=20@ManyToOn?= =?UTF-8?q?e=20=EB=A7=A4=ED=95=91=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= =?UTF-8?q?=20-=20Entity=EC=97=90=EC=84=9C=20ContentId=20=EC=A7=81?= =?UTF-8?q?=EC=A0=91=20=EC=B0=B8=EC=A1=B0=20=EB=8C=80=EC=8B=A0=20@ManyToOn?= =?UTF-8?q?e=20=EC=A0=81=EC=9A=A9=EC=9C=BC=EB=A1=9C=20JPA=20=EC=88=98?= =?UTF-8?q?=EC=A4=80=20=EC=B0=B8=EC=A1=B0=20=EB=AC=B4=EA=B2=B0=EC=84=B1=20?= =?UTF-8?q?=ED=99=95=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/entity/Review.java | 23 ++++++++++++------- .../querydsl/ReviewQueryRepositoryImpl.java | 2 +- .../mopl/review/service/ReviewService.java | 10 ++++---- 3 files changed, 20 insertions(+), 15 deletions(-) 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 index 7abd394a..48a51e85 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -1,10 +1,12 @@ package com.codeit.team5.mopl.review.entity; -import com.codeit.team5.mopl.global.entity.BaseEntity; +import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.global.entity.BaseUpdatableEntity; -import com.codeit.team5.mopl.review.exception.InvalidRatingException; 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; @@ -19,8 +21,9 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Review extends BaseUpdatableEntity { - @Column(name = "content_id", nullable = false, columnDefinition = "uuid") - private UUID contentId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "content_id", nullable = false) + private Content content; @Column(name = "user_id", nullable = false, columnDefinition = "uuid") private UUID authorId; @@ -31,17 +34,21 @@ public class Review extends BaseUpdatableEntity { @Column(nullable = false) private Double rating; - public static Review create(UUID contentId, UUID authorId, String text, Double rating) { - return new Review(contentId, authorId, text, rating); + public static Review create(Content content, UUID authorId, String text, Double rating) { + return new Review(content, authorId, text, rating); } - private Review(UUID contentId, UUID authorId, String text, Double rating) { - this.contentId = contentId; + private Review(Content content, UUID authorId, String text, Double rating) { + this.content = content; this.authorId = authorId; this.text = text; this.rating = rating; } + public UUID getContentId() { + return content.getId(); + } + public void update(String text, Double rating) { if (text != null) { this.text = text; 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 index 0cc99920..c625811a 100644 --- 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 @@ -34,7 +34,7 @@ public List findPageByContentId(UUID contentId, String cursor, UUID idAf OrderSpecifier[] orderSpecifiers = buildOrderSpecifiers(r, sortBy, ascending); return queryFactory.selectFrom(r) - .where(r.contentId.eq(contentId), cursorCondition) + .where(r.content.id.eq(contentId), cursorCondition) .orderBy(orderSpecifiers) .limit(limit.max()) .fetch(); 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 index 00295f68..1abf1a99 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -7,6 +7,7 @@ import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; import com.codeit.team5.mopl.review.dto.request.ReviewUpdateRequest; import com.codeit.team5.mopl.review.dto.response.ReviewResponse; +import com.codeit.team5.mopl.content.entity.Content; import com.codeit.team5.mopl.review.entity.Review; import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; import com.codeit.team5.mopl.review.exception.ReviewAlreadyExistsException; @@ -92,17 +93,14 @@ public CursorResponse getReviews( // 리뷰 생성 @Transactional public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { - // 콘텐츠가 존재하는 지 검증 - if(!contentRepository.existsById(request.contentId())) - { - throw new ContentNotFoundException(request.contentId()); - } + Content content = contentRepository.findById(request.contentId()) + .orElseThrow(() -> new ContentNotFoundException(request.contentId())); // 해당 유저의 리뷰가 이미 존재하면 예외 던지기 if (reviewRepository.existsByContentIdAndAuthorId(request.contentId(), authorId)) { throw new ReviewAlreadyExistsException(); } - Review review = Review.create(request.contentId(), authorId, request.text(), request.rating()); + Review review = Review.create(content, authorId, request.text(), request.rating()); Review saved; try { saved = reviewRepository.saveAndFlush(review); From e65b42990f374cf7c1b21c2a5ff7b424f41ef54c Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Fri, 3 Jul 2026 17:38:54 +0900 Subject: [PATCH 11/32] =?UTF-8?q?Fix:=20=EB=A0=88=ED=8F=AC=EC=A7=80?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=EC=A0=91=EC=9C=BC=EB=A1=9C=20contentId?= =?UTF-8?q?=EB=A5=BC=20=EC=B0=B8=EC=A1=B0=ED=95=B4=EC=84=9C=20=EC=83=9D?= =?UTF-8?q?=EA=B8=B0=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/review/repository/ReviewRepository.java | 4 ++-- .../com/codeit/team5/mopl/review/service/ReviewService.java | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) 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 index 1c1727a8..2db012c8 100644 --- a/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java +++ b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java @@ -7,7 +7,7 @@ public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { - boolean existsByContentIdAndAuthorId(UUID contentId, UUID authorId); + boolean existsByContent_IdAndAuthorId(UUID contentId, UUID authorId); - long countByContentId(UUID contentId); + long countByContent_Id(UUID contentId); } 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 index 1abf1a99..ef674c99 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -75,7 +75,7 @@ public CursorResponse getReviews( } // 총 개수 - long totalCount = reviewRepository.countByContentId(contentId); + long totalCount = reviewRepository.countByContent_Id(contentId); // 페이지의 authorId를 한 번에 모아서 배치 조회 (N+1 방지) Map userMap = userRepository.findAllById( @@ -97,7 +97,7 @@ public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { .orElseThrow(() -> new ContentNotFoundException(request.contentId())); // 해당 유저의 리뷰가 이미 존재하면 예외 던지기 - if (reviewRepository.existsByContentIdAndAuthorId(request.contentId(), authorId)) { + if (reviewRepository.existsByContent_IdAndAuthorId(request.contentId(), authorId)) { throw new ReviewAlreadyExistsException(); } Review review = Review.create(content, authorId, request.text(), request.rating()); From b2508e834fc9cf7b7f49391c234b75125d7cfe07 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sat, 4 Jul 2026 19:51:54 +0900 Subject: [PATCH 12/32] =?UTF-8?q?Feat:=20SecurityConfig=EC=97=90=EC=84=9C?= =?UTF-8?q?=20review=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EA=B0=80=EB=8A=94=20=EC=9A=94=EC=B2=AD=20aut?= =?UTF-8?q?henticated=20=EC=A1=B0=EA=B1=B4=20=EA=B1=B8=EC=96=B4=EB=86=93?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) 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 1a2b9a7c..36fb6cfb 100644 --- a/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java +++ b/src/main/java/com/codeit/team5/mopl/config/SecurityConfig.java @@ -80,6 +80,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/api/follows/**").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() From d345de0a82e4ec5d4180b6cabc018bdd3fe5b729 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sat, 4 Jul 2026 20:28:21 +0900 Subject: [PATCH 13/32] =?UTF-8?q?Refactor:=20review=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EC=83=9D=EC=84=B1=20=EB=A9=94=EC=84=9C=EB=93=9C?= =?UTF-8?q?=EB=A5=BC=20create=EC=97=90=EC=84=9C=20of=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/codeit/team5/mopl/review/entity/Review.java | 2 +- .../com/codeit/team5/mopl/review/service/ReviewService.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 48a51e85..5d735e63 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -34,7 +34,7 @@ public class Review extends BaseUpdatableEntity { @Column(nullable = false) private Double rating; - public static Review create(Content content, UUID authorId, String text, Double rating) { + public static Review of(Content content, UUID authorId, String text, Double rating) { return new Review(content, authorId, text, rating); } 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 index ef674c99..6f4e1cff 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -100,7 +100,7 @@ public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { if (reviewRepository.existsByContent_IdAndAuthorId(request.contentId(), authorId)) { throw new ReviewAlreadyExistsException(); } - Review review = Review.create(content, authorId, request.text(), request.rating()); + Review review = Review.of(content, authorId, request.text(), request.rating()); Review saved; try { saved = reviewRepository.saveAndFlush(review); From 6a4e97bb65bec76f76fd58058177e4fab9ee22a3 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sat, 4 Jul 2026 20:50:10 +0900 Subject: [PATCH 14/32] =?UTF-8?q?Test:=20=EB=A6=AC=EB=B7=B0=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/ReviewRepositoryTest.java | 345 ++++++++++++++++++ 1 file changed, 345 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java 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..d4f41539 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java @@ -0,0 +1,345 @@ +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.notification.exception.CursorIdAfterNotTogetherException; +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 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.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.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; + + // em으로 테스트용 user 객체 persist 후 flush 하는 내부 메서드 + private User persistUser(String email) { + User user = User.create(email, "password", "테스터"); + entityManager.persist(user); + entityManager.flush(); + return user; + } + + // title 받고 content를 생성 및 persist & flush + private Content persistContent(String title) { + Content content = Content.createByAdmin(ContentType.MOVIE, title, null); + entityManager.persist(content); + entityManager.flush(); + return content; + } + + // 컨텐츠, userId, text, 평점을 토대로 리뷰 객체를 생성 및 persist & flush + private Review persistReview(Content content, UUID authorId, String text, double rating) { + Review review = Review.of(content, authorId, text, rating); + reviewRepository.save(review); + entityManager.flush(); + return review; + } + + // 리뷰의 createdAt을 세팅 + private void setCreatedAt(UUID reviewId, Instant createdAt) { + entityManager.createNativeQuery( + "UPDATE reviews SET created_at = :ts WHERE id = :id") + .setParameter("ts", OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC)) // DB에서는 OffsetDataTime으로 다루는 것이 호환성이 좋아 이렇게 변환한다고 합니다. + .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.getId(), "재밌어요", 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.getId(), "첫 리뷰", 4.0)); + + // when & then + assertThatThrownBy(() -> + reviewRepository.saveAndFlush(Review.of(content, author.getId(), "중복 리뷰", 3.0))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); // DB 제약으로 인한 DataIntegrity 예외 발생 + } + + @Test + @DisplayName("existsByContent_IdAndAuthorId: 리뷰가 존재하면 true를 반환한다") + void existsByContent_IdAndAuthorId_exists() { + // given + User author = persistUser("exists@example.com"); + Content content = persistContent("영화3"); + persistReview(content, author.getId(), "리뷰", 4.0); + entityManager.clear(); + + // when & then + assertThat(reviewRepository.existsByContent_IdAndAuthorId(content.getId(), author.getId())).isTrue(); + } + + @Test + @DisplayName("existsByContent_IdAndAuthorId: 리뷰가 없으면 false를 반환한다") + void existsByContent_IdAndAuthorId_notExists() { + // given + User author = persistUser("notexists@example.com"); + Content content = persistContent("영화4"); + + // when & then + assertThat(reviewRepository.existsByContent_IdAndAuthorId(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.getId(), "리뷰1", 4.0); + persistReview(content, author2.getId(), "리뷰2", 3.5); + persistReview(otherContent, author1.getId(), "다른 콘텐츠 리뷰", 5.0); + entityManager.clear(); + + // when & then + assertThat(reviewRepository.countByContent_Id(content.getId())).isEqualTo(2); + } + + @Test + @DisplayName("createdAt 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") + void findPageByContentId_createdAtDesc_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.getId(), "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + Review r4 = persistReview(content, author4.getId(), "r4", 2.0); + setCreatedAt(r4.getId(), Instant.parse("2026-01-01T00:04:00Z")); + Review r5 = persistReview(content, author5.getId(), "r5", 1.5); + setCreatedAt(r5.getId(), Instant.parse("2026-01-01T00:05:00Z")); + persistReview(otherContent, author1.getId(), "other", 4.0); + entityManager.flush(); + entityManager.clear(); + + // when + // 커서,idAfter가 null이므로 첫페이지 fetch + List firstPage = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(2), "createdAt", false); + Review cursor = firstPage.get(firstPage.size() - 1); // 마지막 요소가 cursor + // 이어서 두번째 페이지 fetch + List secondPage = reviewRepository.findPageByContentId( + content.getId(), cursor.getCreatedAt().toString(), cursor.getId(), Limit.of(2), "createdAt", false); + + // then + assertThat(firstPage).hasSize(2); + assertThat(secondPage).hasSize(2); + // 두번째 페이지 id가 첫번째 페이지에 포함 X -> 페이지 겹침 없음 + assertThat(secondPage).extracting(Review::getId) + .doesNotContainAnyElementsOf(firstPage.stream().map(Review::getId).toList()); + } + + @Test + @DisplayName("createdAt 내림차순 조회는 최신 리뷰가 먼저 반환된다") + void findPageByContentId_createdAtDesc_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.getId(), "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(10), "createdAt", false); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getCreatedAt).reversed()); + } + + @Test + @DisplayName("createdAt 올림차순 조회는 오래된 리뷰가 먼저 반환된다") + void findPageByContentId_createdAtAsc_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.getId(), "r1", 4.0); + setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); + Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); + Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(10), "createdAt", true); + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getCreatedAt)); + } + + @Test + @DisplayName("rating 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") + void findPageByContentId_ratingDesc_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("영화10"); + + persistReview(content, author1.getId(), "r1", 5.0); + persistReview(content, author2.getId(), "r2", 4.0); + persistReview(content, author3.getId(), "r3", 3.0); + persistReview(content, author4.getId(), "r4", 2.0); + entityManager.flush(); + entityManager.clear(); + + // when + List firstPage = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(2), "rating", false); + Review cursor = firstPage.get(firstPage.size() - 1); + List secondPage = reviewRepository.findPageByContentId( + content.getId(), cursor.getRating().toString(), cursor.getId(), Limit.of(2), "rating", false); + + // 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 findPageByContentId_ratingDesc_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("영화11"); + + persistReview(content, author1.getId(), "r1", 3.0); + persistReview(content, author2.getId(), "r2", 5.0); // 먼저 반환될 리뷰 + persistReview(content, author3.getId(), "r3", 1.0); // 제일 뒤에 반환될 리뷰 + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(10), "rating", false); // 내림차순 + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getRating).reversed()); + } + + @Test + @DisplayName("rating 올림차순 조회는 낮은 평점이 먼저 반환된다") + void findPageByContentId_ratingAsc_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("영화11"); + + persistReview(content, author1.getId(), "r1", 3.0); + persistReview(content, author2.getId(), "r2", 5.0); // 제일 뒤에 위치한 리뷰 + persistReview(content, author3.getId(), "r3", 1.0); // 제일 앞에 위치한 리뷰 + entityManager.flush(); + entityManager.clear(); + + // when + List all = reviewRepository.findPageByContentId( + content.getId(), null, null, Limit.of(10), "rating", true); // 올림차순 + + // then + assertThat(all).hasSize(3); + assertThat(all).isSortedAccordingTo(Comparator.comparing(Review::getRating)); + } + + @Test + @DisplayName("cursor와 idAfter 중 하나만 주어지면 예외가 발생한다") + void findPageByContentId_cursorIdAfterNotTogether_exception() { + // given + UUID contentId = UUID.randomUUID(); + + // when & then + // 보조커서(idAfter)가 null이면 예외 발생 + assertThatThrownBy(() -> reviewRepository.findPageByContentId( + contentId, Instant.now().toString(), null, Limit.of(2), "createdAt", false)) + .isInstanceOf(CursorIdAfterNotTogetherException.class); + + // 주 커서 (cursor)가 null이면 예외 발생 + assertThatThrownBy(() -> reviewRepository.findPageByContentId( + contentId, null, UUID.randomUUID(), Limit.of(2), "createdAt", false)) + .isInstanceOf(CursorIdAfterNotTogetherException.class); + } +} From 2e7682a4d3dc1d8b383b6a6014277f3271ac20bb Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 09:48:53 +0900 Subject: [PATCH 15/32] =?UTF-8?q?Test:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=9E=91=EC=84=B1=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/ReviewControllerTest.java | 489 ++++++++++++++++++ .../review/service/ReviewServiceTest.java | 367 +++++++++++++ 2 files changed, 856 insertions(+) create mode 100644 src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java create mode 100644 src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java 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..5a17a22a --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java @@ -0,0 +1,489 @@ +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.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.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 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( + eq(contentId), eq(null), eq(null), eq(20), eq("DESCENDING"), eq("createdAt"))) + .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( + eq(contentId), eq(cursor), eq(idAfter), eq(10), eq("ASCENDING"), eq("rating"))) + .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", "ASCENDING") + .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(), any(), any(), any(int.class), any(), any()); + } + + @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(), any(), any(), any(int.class), any(), any()); + } + + // ===== 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/service/ReviewServiceTest.java b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java new file mode 100644 index 00000000..d520e95f --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -0,0 +1,367 @@ +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.ArgumentMatchers.anyList; +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.global.exception.InvalidSortDirectionException; +import com.codeit.team5.mopl.review.dto.request.ReviewCreateRequest; +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.InvalidReviewSortByException; +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.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.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Limit; + +@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(); + UUID authorId = 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(r0.getAuthorId()).willReturn(authorId); + given(r1.getAuthorId()).willReturn(authorId); + given(r1.getCreatedAt()).willReturn(lastCreatedAt); + given(r1.getId()).willReturn(lastId); + + given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", false)) + .willReturn(List.of(r0, r1, r2)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(5L); + User user = mock(User.class); + given(user.getId()).willReturn(authorId); + given(userRepository.findAllById(anyList())).willReturn(List.of(user)); + given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews( + contentId, null, null, 2, "DESCENDING", "createdAt"); + + // 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(); + UUID authorId = 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(r0.getAuthorId()).willReturn(authorId); + given(r1.getAuthorId()).willReturn(authorId); + given(r1.getRating()).willReturn(lastRating); + given(r1.getId()).willReturn(lastId); + + given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "rating", false)) + .willReturn(List.of(r0, r1, r2)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(5L); + User user = mock(User.class); + given(user.getId()).willReturn(authorId); + given(userRepository.findAllById(anyList())).willReturn(List.of(user)); + given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews( + contentId, null, null, 2, "DESCENDING", "rating"); + + // 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(); + UUID authorId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + given(r0.getAuthorId()).willReturn(authorId); + given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", false)) + .willReturn(List.of(r0)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(1L); + User user = mock(User.class); + given(user.getId()).willReturn(authorId); + given(userRepository.findAllById(anyList())).willReturn(List.of(user)); + given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews( + contentId, null, null, 2, "DESCENDING", "createdAt"); + + // then + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + assertThat(result.nextIdAfter()).isNull(); + } + + @Test + @DisplayName("지원하지 않는 sortBy면 예외가 발생한다") + void getReviews_invalidSortBy_exception() { + // given + UUID contentId = UUID.randomUUID(); + + // when & then + assertThatThrownBy(() -> reviewService.getReviews( + contentId, null, null, 2, "DESCENDING", "title")) + .isInstanceOf(InvalidReviewSortByException.class); + } + + @Test + @DisplayName("지원하지 않는 sortDirection이면 예외가 발생한다") + void getReviews_invalidSortDirection_exception() { + // given + UUID contentId = UUID.randomUUID(); + + // when & then + assertThatThrownBy(() -> reviewService.getReviews( + contentId, null, null, 2, "WHATEVER", "createdAt")) + .isInstanceOf(InvalidSortDirectionException.class); + } + + @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); + Review saved = mock(Review.class); + User user = mock(User.class); + ReviewResponse response = mock(ReviewResponse.class); + + given(contentRepository.findById(contentId)).willReturn(Optional.of(content)); + given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); + given(reviewRepository.saveAndFlush(any())).willReturn(saved); + given(saved.getAuthorId()).willReturn(authorId); + given(saved.getId()).willReturn(UUID.randomUUID()); + given(saved.getContentId()).willReturn(contentId); + given(userRepository.findById(authorId)).willReturn(Optional.of(user)); + given(reviewMapper.toDto(saved, user)).willReturn(response); + + // when + ReviewResponse result = reviewService.createReview(authorId, request); + + // then + assertThat(result).isEqualTo(response); + verify(reviewRepository).saveAndFlush(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(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> reviewService.createReview(authorId, request)) + .isInstanceOf(ReviewAlreadyExistsException.class); + } + + @Test + @DisplayName("race condition으로 DB unique 제약 위반 시 ReviewAlreadyExistsException을 던진다") + void ofReview_raceConditionUniqueViolation_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(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); + + DataIntegrityViolationException ex = new DataIntegrityViolationException( + "duplicate key value violates unique constraint \"uk_reviews_content_id_user_id\""); + given(reviewRepository.saveAndFlush(any())).willThrow(ex); + + // when & then + assertThatThrownBy(() -> reviewService.createReview(authorId, request)) + .isInstanceOf(ReviewAlreadyExistsException.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); + User author = mock(User.class); + ReviewResponse response = mock(ReviewResponse.class); + + given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(authorId); + given(userRepository.findById(authorId)).willReturn(Optional.of(author)); + given(reviewMapper.toDto(review, author)).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.findById(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.findById(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(otherId); + + // when & then + assertThatThrownBy(() -> reviewService.updateReview( + reviewId, authorId, new ReviewUpdateRequest(null, null))) + .isInstanceOf(ReviewForbiddenException.class); + } + + @Test + @DisplayName("리뷰 삭제에 성공한다") + void deleteReview_success() { + // given + UUID reviewId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + Review review = mock(Review.class); + given(reviewRepository.findById(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.findById(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.findById(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(otherId); + + // when & then + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, authorId)) + .isInstanceOf(ReviewForbiddenException.class); + } +} From 46696b4d1f57d9a0d43f9727c5b24defe708943a Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 10:22:50 +0900 Subject: [PATCH 16/32] =?UTF-8?q?Test:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=86=B5=ED=95=A9=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewControllerIntegrationTest.java | 350 ++++++++++++++++++ .../review/service/ReviewServiceTest.java | 11 +- 2 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java 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..f26758f7 --- /dev/null +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java @@ -0,0 +1,350 @@ +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.time.Instant; +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, UUID authorId, String text, double rating) { + return reviewRepository.saveAndFlush(Review.of(content, authorId, 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.getId(), "재밌어요", 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.getId(), "내 리뷰", 4.0); + persistReview(otherContent, author.getId(), "다른 리뷰", 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.getId(), "리뷰1", 5.0); + persistReview(content, author2.getId(), "리뷰2", 4.0); + persistReview(content, author3.getId(), "리뷰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", "DESCENDING")) + .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", "DESCENDING")) + .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_IdAndAuthorId(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.getId(), "첫 리뷰", 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.getId(), "원래 내용", 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.getId(), "원래 내용", 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.getId(), "삭제할 리뷰", 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.getId(), "남의 리뷰", 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/service/ReviewServiceTest.java b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java index d520e95f..dea65608 100644 --- a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -66,11 +66,11 @@ void getReviews_hasNext_createdAtCursor() { UUID lastId = UUID.randomUUID(); Review r0 = mock(Review.class); - Review r1 = mock(Review.class); + Review r1 = mock(Review.class); // 첫 번쨰 페이지 마지막 리뷰 요소 Review r2 = mock(Review.class); given(r0.getAuthorId()).willReturn(authorId); given(r1.getAuthorId()).willReturn(authorId); - given(r1.getCreatedAt()).willReturn(lastCreatedAt); + given(r1.getCreatedAt()).willReturn(lastCreatedAt); // 해당 리뷰에 cursor&idAfter 삽입 given(r1.getId()).willReturn(lastId); given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", false)) @@ -191,8 +191,9 @@ void ofReview_success() { User user = mock(User.class); ReviewResponse response = mock(ReviewResponse.class); + // reviewService.createReview 내에서 실행되는 타 계층 반환값들 세팅 given(contentRepository.findById(contentId)).willReturn(Optional.of(content)); - given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); + given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); // 리뷰 중복 작성 여부 given(reviewRepository.saveAndFlush(any())).willReturn(saved); given(saved.getAuthorId()).willReturn(authorId); given(saved.getId()).willReturn(UUID.randomUUID()); @@ -247,6 +248,7 @@ void ofReview_raceConditionUniqueViolation_exception() { UUID contentId = UUID.randomUUID(); ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); + // createReview 호출 시 호출되는 메서드들 반환 값 세팅 given(contentRepository.findById(contentId)).willReturn(Optional.of(mock(Content.class))); given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); @@ -291,6 +293,7 @@ void updateReview_notFound_exception() { UUID reviewId = UUID.randomUUID(); UUID authorId = UUID.randomUUID(); + // 존재하지 않는 리뷰 -> Optional<> 반환 given(reviewRepository.findById(reviewId)).willReturn(Optional.empty()); // when & then @@ -358,7 +361,7 @@ void deleteReview_forbidden_exception() { Review review = mock(Review.class); given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); - given(review.getAuthorId()).willReturn(otherId); + given(review.getAuthorId()).willReturn(otherId); // review.authorId = other // when & then assertThatThrownBy(() -> reviewService.deleteReview(reviewId, authorId)) From 509a4cb47655acf4d183baff96a04a7baa16efdc Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 18:32:48 +0900 Subject: [PATCH 17/32] =?UTF-8?q?Refactor:=20=EC=82=AC=EC=9A=A9=EB=90=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=ED=8F=89=EC=A0=90=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20MoplPrincipal?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B8=EC=A6=9D=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EB=B0=9B=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/review/controller/ReviewController.java | 7 +++---- .../team5/mopl/review/controller/api/ReviewApi.java | 3 +-- .../review/exception/InvalidRatingException.java | 12 ------------ 3 files changed, 4 insertions(+), 18 deletions(-) delete mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java 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 index c583bb39..212504f2 100644 --- a/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java +++ b/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java @@ -1,7 +1,6 @@ package com.codeit.team5.mopl.review.controller; import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; -import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; 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; @@ -85,12 +84,12 @@ public ResponseEntity updateReview( @Override @DeleteMapping("/{reviewId}") public ResponseEntity deleteReview( - @AuthenticationPrincipal MoplUserDetails userDetails, + @AuthenticationPrincipal MoplPrincipal moplPrincipal, @PathVariable UUID reviewId) { - log.info("리뷰 삭제: DELETE /api/reviews/{}, authorId={}", reviewId, userDetails.getId()); + log.info("리뷰 삭제: DELETE /api/reviews/{}, authorId={}", reviewId, moplPrincipal.getId()); - reviewService.deleteReview(reviewId, userDetails.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 index eddc3045..1de2ccc6 100644 --- 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 @@ -1,7 +1,6 @@ package com.codeit.team5.mopl.review.controller.api; import com.codeit.team5.mopl.auth.security.details.MoplPrincipal; -import com.codeit.team5.mopl.auth.security.details.MoplUserDetails; 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; @@ -100,7 +99,7 @@ ResponseEntity updateReview( content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity deleteReview( - @Parameter(hidden = true) MoplUserDetails userDetails, + @Parameter(hidden = true) MoplPrincipal moplPrincipal, @Parameter(description = "리뷰 ID", required = true) @PathVariable UUID reviewId); } diff --git a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java deleted file mode 100644 index ccaa86df..00000000 --- a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidRatingException.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.codeit.team5.mopl.review.exception; - -import com.codeit.team5.mopl.global.exception.BusinessException; -import org.springframework.http.HttpStatus; - -public class InvalidRatingException extends BusinessException { - - public InvalidRatingException(Double rating) { - - super(HttpStatus.BAD_REQUEST, "평점은 0.0 이상, 5.0 이하여야 합니다. rating: "+ rating); - } -} From 05778043cdfbf420aa9bf397d3bb2059b924bcaa Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 18:48:00 +0900 Subject: [PATCH 18/32] =?UTF-8?q?Refactor:=20update=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=98=20=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20?= =?UTF-8?q?-=20PATCH=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=8B=9C=EB=A7=A8?= =?UTF-8?q?=ED=8B=B1=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EB=B6=80=EB=B6=84?= =?UTF-8?q?=EC=A0=81=EC=9D=B8=20=ED=95=84=EB=93=9C=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EC=9E=A5=ED=95=A8=EA=B3=BC=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=EC=97=90=20Review=20=EC=97=94=ED=8B=B0=ED=8B=B0?= =?UTF-8?q?=EC=9D=98=20update=20=EB=A9=94=EC=86=8C=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20-=20=EB=A6=AC=EB=B7=B0=20UPDATE=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20DTO=EC=9D=98=20@NotNull,=20@NotBlank=20?= =?UTF-8?q?=EC=96=B4=EB=85=B8=ED=85=8C=EC=9D=B4=EC=85=98=EC=9D=84=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/dto/request/ReviewUpdateRequest.java | 4 ---- src/main/java/com/codeit/team5/mopl/review/entity/Review.java | 4 +++- 2 files changed, 3 insertions(+), 5 deletions(-) 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 index 957e1a14..d6542059 100644 --- 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 @@ -3,17 +3,13 @@ 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; @Schema(name = "ReviewUpdateRequest", description = "리뷰 수정 요청") public record ReviewUpdateRequest( @Schema(description = "리뷰 내용") - @NotBlank(message = "리뷰 내용은 필수입니다.") String text, @Schema(description = "평점 (0.0 ~ 5.0)") - @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/entity/Review.java b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java index 5d735e63..b966a109 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -53,6 +53,8 @@ public void update(String text, Double rating) { if (text != null) { this.text = text; } - this.rating = rating; + if(rating != null){ + this.rating = rating; + } } } From d0a608c49f64350b27363e95adce473d228f2573 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 18:54:23 +0900 Subject: [PATCH 19/32] =?UTF-8?q?Refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=ED=83=80=EC=9D=B8=EC=9D=98=20=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=A0=20=EB=95=8C=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=EB=A9=94=EC=86=8C=EB=93=9C=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EC=88=98=EC=A0=95=20-=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=ED=94=BC=EB=93=9C=EB=B0=B1=EB=8C=80?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95=20=EC=A0=84=EC=97=90=EB=8A=94=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20dto=EC=97=90=20=EB=91=90=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20null=EB=A1=9C=20=EC=A0=84=EB=8B=AC=ED=96=88?= =?UTF-8?q?=EB=8A=94=EB=8D=B0=20=EB=91=90=20=EA=B0=92=EC=9D=84=20=EC=9D=98?= =?UTF-8?q?=EB=AF=B8=20=EC=9E=88=EB=8A=94=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/review/service/ReviewServiceTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index dea65608..1f9a8006 100644 --- a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -316,7 +316,7 @@ void updateReview_forbidden_exception() { // when & then assertThatThrownBy(() -> reviewService.updateReview( - reviewId, authorId, new ReviewUpdateRequest(null, null))) + reviewId, authorId, new ReviewUpdateRequest("수정내용", 4.0))) .isInstanceOf(ReviewForbiddenException.class); } From d4ceca0bacfdcb10d35a9451a1c4e39db5d5f99f Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 19:02:21 +0900 Subject: [PATCH 20/32] =?UTF-8?q?Fix:=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=20=EC=B6=94=EA=B0=80=EB=90=9C=20JwtAuthen?= =?UTF-8?q?ticationService=EB=A5=BC=20mocking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/controller/ReviewControllerTest.java | 4 ++++ 1 file changed, 4 insertions(+) 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 index 5a17a22a..1c1a7b79 100644 --- a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java @@ -17,6 +17,7 @@ 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; @@ -79,6 +80,9 @@ class ReviewControllerTest { @MockitoBean private JwtTokenizer jwtTokenizer; + @MockitoBean + private JwtAuthenticationService jwtAuthenticationService; + @MockitoBean private MoplUserDetailsService userDetailsService; From 94c6a533f9a803c3a3efdf57fb8e0777f2f4709a Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 19:02:41 +0900 Subject: [PATCH 21/32] =?UTF-8?q?Refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Ascending=20=EC=A0=95=EB=A0=AC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/service/ReviewServiceTest.java | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) 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 index 1f9a8006..842fea3c 100644 --- a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -56,6 +56,13 @@ class ReviewServiceTest { @InjectMocks private ReviewService reviewService; + @Test + @DisplayName("리뷰 조회 시 createdAt 기준 내림차순 Descending 조회를 확인한다.") + void getReviews_get_createdAtCursor_descending(){ + + } + + @Test @DisplayName("다음 페이지가 있으면 limit만큼 자르고 createdAt 기준 nextCursor를 채운다") void getReviews_hasNext_createdAtCursor() { @@ -154,6 +161,31 @@ void getReviews_lastPage() { assertThat(result.nextIdAfter()).isNull(); } + @Test + @DisplayName("sortDirection이 ASCENDING이면 ascending=true로 리포지토리를 호출한다") + void getReviews_ascending() { + // given + UUID contentId = UUID.randomUUID(); + UUID authorId = UUID.randomUUID(); + + Review r0 = mock(Review.class); + given(r0.getAuthorId()).willReturn(authorId); + given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", true)) + .willReturn(List.of(r0)); + given(reviewRepository.countByContent_Id(contentId)).willReturn(1L); + User user = mock(User.class); + given(user.getId()).willReturn(authorId); + given(userRepository.findAllById(anyList())).willReturn(List.of(user)); + given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + + // when + CursorResponse result = reviewService.getReviews( + contentId, null, null, 2, "ASCENDING", "createdAt"); + + // then + assertThat(result.hasNext()).isFalse(); + } + @Test @DisplayName("지원하지 않는 sortBy면 예외가 발생한다") void getReviews_invalidSortBy_exception() { From 51f290eb551542ae3ba80675f3dc193d72ff11c3 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 19:32:22 +0900 Subject: [PATCH 22/32] =?UTF-8?q?Refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=9E=84=EC=8B=9C=EB=A1=9C=20=EB=A7=8C=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EB=86=A8=EC=97=88=EB=96=A4=20=EB=B9=88=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/service/ReviewServiceTest.java | 7 ------- 1 file changed, 7 deletions(-) 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 index 842fea3c..5e12a0fd 100644 --- a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -56,13 +56,6 @@ class ReviewServiceTest { @InjectMocks private ReviewService reviewService; - @Test - @DisplayName("리뷰 조회 시 createdAt 기준 내림차순 Descending 조회를 확인한다.") - void getReviews_get_createdAtCursor_descending(){ - - } - - @Test @DisplayName("다음 페이지가 있으면 limit만큼 자르고 createdAt 기준 nextCursor를 채운다") void getReviews_hasNext_createdAtCursor() { From b082c2ca9a8fe6bc468e9c583b672b1227ea619c Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 23:03:59 +0900 Subject: [PATCH 23/32] =?UTF-8?q?Refactor:=20SortBy=20enum=ED=99=94=20?= =?UTF-8?q?=EB=B0=8F=20Review=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=ED=8C=A8?= =?UTF-8?q?=ED=82=A4=EC=A7=80=20=EB=82=B4=20=EC=98=88=EC=99=B8=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/review/contant/ReviewSortBy.java | 23 +++++++++++++++ .../mopl/review/controller/api/ReviewApi.java | 17 ++--------- .../review/dto/request/ReviewGetRequest.java | 28 +++++++++++++++++++ .../CursorIdAfterNotTogetherException.java | 0 4 files changed, 53 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewGetRequest.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/CursorIdAfterNotTogetherException.java 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..751acfd2 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java @@ -0,0 +1,23 @@ +package com.codeit.team5.mopl.review.enums; + +import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; +import java.util.Arrays; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ReviewSortBy { + CREATED_AT("createdAt"), + RATING("rating"), + ; + + private final String value; + + public static ReviewSortBy from(String value) { + return Arrays.stream(values()) + .filter(sortBy -> sortBy.value.equalsIgnoreCase(value)) + .findFirst() + .orElseThrow(() -> new InvalidReviewSortByException(value)); + } +} 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 index 1de2ccc6..333f3d61 100644 --- 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 @@ -4,6 +4,7 @@ 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; @@ -14,13 +15,10 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; import java.util.UUID; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "리뷰 관리", description = "리뷰 API") public interface ReviewApi { @@ -36,18 +34,7 @@ public interface ReviewApi { content = @Content(schema = @Schema(implementation = ErrorResponseSuggestion.class))) }) ResponseEntity> getReviews( - @Parameter(description = "콘텐츠 ID", required = true) - @RequestParam UUID contentId, - @Parameter(description = "커서") - @RequestParam(required = false) String cursor, - @Parameter(description = "보조 커서") - @RequestParam(required = false) UUID idAfter, - @Parameter(description = "한 번에 가져올 개수") - @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit, - @Parameter(description = "정렬 방향 (ASCENDING, DESCENDING)") - @RequestParam(defaultValue = "DESCENDING") String sortDirection, - @Parameter(description = "정렬 기준 (createdAt, rating)") - @RequestParam(defaultValue = "createdAt") String sortBy); + @Valid ReviewGetRequest request); @Operation(operationId = "createReview", summary = "리뷰 생성", description = "생성한 리뷰는 API 요청자 본인의 리뷰로 생성됩니다.") 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..022d66c4 --- /dev/null +++ b/src/main/java/com/codeit/team5/mopl/review/dto/request/ReviewGetRequest.java @@ -0,0 +1,28 @@ +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, + + @NotNull + Sort.Direction sortDirection, + + @NotNull + String sortBy +) { +} 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..e69de29b From 2bebe352f9be3bac927b20afbdc197cc32fa0809 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Sun, 5 Jul 2026 23:05:04 +0900 Subject: [PATCH 24/32] =?UTF-8?q?Refactor:=20SortBy=20enum=EA=B3=BC=20GetR?= =?UTF-8?q?equestDto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/contant/ReviewSortBy.java | 2 +- .../mopl/review/dto/request/ReviewGetRequest.java | 2 +- .../CursorIdAfterNotTogetherException.java | 13 +++++++++++++ 3 files changed, 15 insertions(+), 2 deletions(-) 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 index 751acfd2..e023d9c4 100644 --- a/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java +++ b/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java @@ -1,4 +1,4 @@ -package com.codeit.team5.mopl.review.enums; +package com.codeit.team5.mopl.review.contant; import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; import java.util.Arrays; 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 index 022d66c4..acd95654 100644 --- 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 @@ -23,6 +23,6 @@ public record ReviewGetRequest( Sort.Direction sortDirection, @NotNull - String sortBy + ReviewSortBy sortBy ) { } 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 index e69de29b..2bad45ef 100644 --- a/src/main/java/com/codeit/team5/mopl/review/exception/CursorIdAfterNotTogetherException.java +++ 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가 같이 제공되지 않습니다."); + } +} From e9192cd5075207148c1efddccfae52b7007e7284 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 00:26:17 +0900 Subject: [PATCH 25/32] =?UTF-8?q?Refactor:=20Playlist=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0=ED=95=98=EC=97=AC=20parser=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=EA=B3=BC=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mopl/review/contant/ReviewSortBy.java | 31 +++++++++++++------ .../exception/InvalidCursorException.java | 0 .../ReviewSortByMismatchException.java | 0 3 files changed, 22 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java create mode 100644 src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java 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 index e023d9c4..7e5a5c27 100644 --- a/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java +++ b/src/main/java/com/codeit/team5/mopl/review/contant/ReviewSortBy.java @@ -1,23 +1,36 @@ package com.codeit.team5.mopl.review.contant; import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; -import java.util.Arrays; +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"), - RATING("rating"), + CREATED_AT("createdAt", Instant::parse), + RATING("rating", Double::parseDouble), ; - private final String value; + private final String sortByType; + private final Function parser; - public static ReviewSortBy from(String value) { - return Arrays.stream(values()) - .filter(sortBy -> sortBy.value.equalsIgnoreCase(value)) - .findFirst() - .orElseThrow(() -> new InvalidReviewSortByException(value)); + 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/exception/InvalidCursorException.java b/src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java new file mode 100644 index 00000000..e69de29b 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..e69de29b From 7ac921bdfd4405d70e87d03c53658d2a94c37293 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 00:29:22 +0900 Subject: [PATCH 26/32] =?UTF-8?q?Refactor:=20Review=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=EA=B0=80=20User=20=EA=B0=9D=EC=B2=B4=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/entity/Review.java | 18 ++++++++++++------ .../team5/mopl/review/mapper/ReviewMapper.java | 9 ++------- 2 files changed, 14 insertions(+), 13 deletions(-) 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 index b966a109..0c310012 100644 --- a/src/main/java/com/codeit/team5/mopl/review/entity/Review.java +++ b/src/main/java/com/codeit/team5/mopl/review/entity/Review.java @@ -2,6 +2,7 @@ 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; @@ -25,8 +26,9 @@ public class Review extends BaseUpdatableEntity { @JoinColumn(name = "content_id", nullable = false) private Content content; - @Column(name = "user_id", nullable = false, columnDefinition = "uuid") - private UUID authorId; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User author; @Column(columnDefinition = "TEXT", nullable = false) private String text; @@ -34,13 +36,13 @@ public class Review extends BaseUpdatableEntity { @Column(nullable = false) private Double rating; - public static Review of(Content content, UUID authorId, String text, Double rating) { - return new Review(content, authorId, text, rating); + public static Review of(Content content, User author, String text, Double rating) { + return new Review(content, author, text, rating); } - private Review(Content content, UUID authorId, String text, Double rating) { + private Review(Content content, User author, String text, Double rating) { this.content = content; - this.authorId = authorId; + this.author = author; this.text = text; this.rating = rating; } @@ -49,6 +51,10 @@ 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; 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 index 0214e719..b2e2c0da 100644 --- a/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java +++ b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java @@ -14,13 +14,8 @@ ) public interface ReviewMapper { - @Mapping(target = "id", source = "review.id") - @Mapping(target = "contentId", source = "review.contentId") - @Mapping(target = "text", source = "review.text") - @Mapping(target = "rating", source = "review.rating") - @Mapping(target = "author", source = "user") - ReviewResponse toDto(Review review, User user); - + @Mapping(target = "author", source = "author") + ReviewResponse toDto(Review review); @Mapping(target = "userId", source = "id") @Mapping(target = "profileImageUrl", source = "profileImage.url") From e7fe2a9e87bc4a24d12569434d0b1514e117c182 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 00:31:58 +0900 Subject: [PATCH 27/32] =?UTF-8?q?Refactor:=20=EC=84=9C=EB=B9=84=EC=8A=A4?= =?UTF-8?q?=20&=20=EB=A0=88=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=B1=85=EC=9E=84=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20-=20Cursor,SortBy=EC=99=80=20=EA=B0=99?= =?UTF-8?q?=EC=9D=80=20=EC=BB=A4=EC=84=9C=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EB=84=A4=EC=9D=B4=EC=85=98=EC=97=90=20=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=93=A4?= =?UTF-8?q?=EC=9D=80=20=EC=84=9C=EB=B9=84=EC=8A=A4=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=B5=9C=EB=8C=80=ED=95=9C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=ED=95=98=EB=8A=94=20=EB=B0=A9=ED=96=A5=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A7=84=ED=96=89=ED=95=98=EC=98=80=EC=8A=B5?= =?UTF-8?q?=EB=8B=88=EB=8B=A4.=20-=20Controller,=20Service=EB=8A=94=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EC=BB=A4=EC=84=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=84=A4=EC=9D=B4=EC=85=98=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EB=A5=BC=20=ED=95=A0=20=EB=95=8C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8?= =?UTF-8?q?=ED=84=B0=20=EB=AD=89=EC=B9=98=EB=A5=BC=20=EB=B0=9B=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B3=A0=20Dto=EB=A5=BC=20=EB=B0=9B=EA=B3=A0=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=ED=95=A9=EB=8B=88=EB=8B=A4.=20-=20=EB=A0=88?= =?UTF-8?q?=ED=8F=AC=EC=A7=80=ED=86=A0=EB=A6=AC=EB=8A=94=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=EB=A5=BC=20=EB=84=A3=EA=B3=A0=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=ED=95=98=EB=8A=94=20=EA=B2=83=EC=97=90=EB=A7=8C=20?= =?UTF-8?q?=EC=A7=91=EC=A4=91=ED=95=98=EB=8F=84=EB=A1=9D=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EB=A5=BC=20=EA=B0=9C=ED=8E=B8=ED=95=98=EC=98=80?= =?UTF-8?q?=EC=8A=B5=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../review/controller/ReviewController.java | 22 +-- .../exception/InvalidCursorException.java | 11 ++ .../ReviewSortByMismatchException.java | 14 ++ .../review/repository/ReviewRepository.java | 8 +- .../querydsl/ReviewQueryRepository.java | 8 +- .../querydsl/ReviewQueryRepositoryImpl.java | 91 +++++------ .../mopl/review/service/ReviewService.java | 143 +++++++++--------- 7 files changed, 155 insertions(+), 142 deletions(-) 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 index 212504f2..7958e634 100644 --- a/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java +++ b/src/main/java/com/codeit/team5/mopl/review/controller/ReviewController.java @@ -4,15 +4,15 @@ 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 jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; 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; @@ -24,7 +24,6 @@ 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -38,18 +37,11 @@ public class ReviewController implements ReviewApi { @Override @GetMapping - public ResponseEntity> getReviews( - @RequestParam UUID contentId, - @RequestParam(required = false) String cursor, - @RequestParam(required = false) UUID idAfter, - @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit, - @RequestParam(defaultValue = "DESCENDING") String sortDirection, - @RequestParam(defaultValue = "createdAt") String sortBy) { - - log.info("리뷰 목록 조회: GET /api/reviews, contentId={}", contentId); - - CursorResponse response = reviewService.getReviews( - contentId, cursor, idAfter, limit, sortDirection, sortBy); + public ResponseEntity> getReviews(@Valid ReviewGetRequest request) { + + log.info("리뷰 목록 조회: GET /api/reviews, contentId={}", request.contentId()); + + CursorResponse response = reviewService.getReviews(request); return ResponseEntity.ok(response); } 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 index e69de29b..ee914bd5 100644 --- a/src/main/java/com/codeit/team5/mopl/review/exception/InvalidCursorException.java +++ 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/ReviewSortByMismatchException.java b/src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java index e69de29b..10baf123 100644 --- a/src/main/java/com/codeit/team5/mopl/review/exception/ReviewSortByMismatchException.java +++ 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/repository/ReviewRepository.java b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java index 2db012c8..661a88c8 100644 --- a/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java +++ b/src/main/java/com/codeit/team5/mopl/review/repository/ReviewRepository.java @@ -2,12 +2,18 @@ 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 { - boolean existsByContent_IdAndAuthorId(UUID contentId, UUID authorId); + @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 index 3a4102dd..aa400c88 100644 --- 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 @@ -1,12 +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 findPageByContentId(UUID contentId, String cursor, UUID idAfter, - Limit limit, String sortBy, boolean ascending); + 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 index c625811a..56d8840c 100644 --- 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 @@ -1,92 +1,73 @@ package com.codeit.team5.mopl.review.repository.querydsl; -import com.codeit.team5.mopl.notification.exception.CursorIdAfterNotTogetherException; -import com.codeit.team5.mopl.notification.exception.InvalidCursorException; 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.time.format.DateTimeParseException; 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 static final String SORT_BY_CREATED_AT = "createdAt"; - private static final String SORT_BY_RATING = "rating"; - private final JPAQueryFactory queryFactory; - @Override - public List findPageByContentId(UUID contentId, String cursor, UUID idAfter, - Limit limit, String sortBy, boolean ascending) { + QReview r = QReview.review; - QReview r = QReview.review; + @Override + public List findPageByContentIdSortByRating(UUID contentId, Double cursor, UUID idAfter, + Limit limit, Sort.Direction sortDirection) { - BooleanExpression cursorCondition = buildCursorCondition(r, cursor, idAfter, sortBy, ascending); - OrderSpecifier[] orderSpecifiers = buildOrderSpecifiers(r, sortBy, ascending); + 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(orderSpecifiers) + .orderBy(ratingOrder(sortDirection)) .limit(limit.max()) .fetch(); } - // 커서 페이지네이션을 위한 BooleanExpression 생성 - private BooleanExpression buildCursorCondition(QReview r, String cursor, UUID idAfter, - String sortBy, boolean ascending) { - - // cursor와 idAfter를 한 세트로 받아오기 - if((cursor != null && idAfter == null) || (cursor == null && idAfter != null)) { - throw new CursorIdAfterNotTogetherException(); - } + @Override + public List findPageByContentIdSortByCreatedAt(UUID contentId, Instant cursor, UUID idAfter, + Limit limit, Sort.Direction sortDirection) { - // 첫 페이지면 커서가 없으니 조건 없음 (전체 조회) - if (cursor == null) { - return null; + 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))); } - if (SORT_BY_RATING.equals(sortBy)) { // 점수 기준 정렬 - double cursorRating; - try { - cursorRating = Double.parseDouble(cursor); // string 커서를 double 형식으로 파싱 - } catch (NumberFormatException e) { - throw new InvalidCursorException(); - } - return ascending - // 커서 평점보다 평점이 큰 리뷰, 두개가 같으면 보조 커서를 기준으로 정렬 - ? r.rating.gt(cursorRating).or(r.rating.eq(cursorRating).and(r.id.gt(idAfter))) - : r.rating.lt(cursorRating).or(r.rating.eq(cursorRating).and(r.id.lt(idAfter))); - } + return queryFactory.selectFrom(r) + .join(r.author).fetchJoin() + .where(r.content.id.eq(contentId), cursorCondition) + .orderBy(createdAtOrder(sortDirection)) + .limit(limit.max()) + .fetch(); + } - Instant cursorInstant; // createdAt 기준 정렬 - try { - cursorInstant = Instant.parse(cursor); // string 커서 -> Instant 파싱 - } catch (DateTimeParseException e) { - throw new InvalidCursorException(); - } - return ascending - ? r.createdAt.gt(cursorInstant).or(r.createdAt.eq(cursorInstant).and(r.id.gt(idAfter))) - : r.createdAt.lt(cursorInstant).or(r.createdAt.eq(cursorInstant).and(r.id.lt(idAfter))); + 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()}; } - // orderBy를 적용하기 위해 OrderSpecifier 생성 - private OrderSpecifier[] buildOrderSpecifiers(QReview r, String sortBy, boolean ascending) { - if (SORT_BY_RATING.equals(sortBy)) { // 평점 순 정렬 - return ascending - ? new OrderSpecifier[] {r.rating.asc(), r.id.asc()} - : new OrderSpecifier[] {r.rating.desc(), r.id.desc()}; - } - return ascending // createdAt 순 정렬 - ? new OrderSpecifier[] {r.createdAt.asc(), r.id.asc()} - : new OrderSpecifier[] {r.createdAt.desc(), r.id.desc()}; + 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 index 6f4e1cff..e64188df 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -1,15 +1,18 @@ 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.global.exception.InvalidSortDirectionException; + +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.content.entity.Content; import com.codeit.team5.mopl.review.entity.Review; -import com.codeit.team5.mopl.review.exception.InvalidReviewSortByException; +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; @@ -18,14 +21,14 @@ 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.Map; import java.util.UUID; -import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort.Direction; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,90 +38,87 @@ @Transactional(readOnly = true) public class ReviewService { - private static final String SORT_BY_CREATED_AT = "createdAt"; - private static final String SORT_BY_RATING = "rating"; - private static final String SORT_ASCENDING = "ASCENDING"; - private static final String SORT_DESCENDING = "DESCENDING"; + 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( - UUID contentId, String cursor, UUID idAfter, int limit, - String sortDirection, String sortBy) { - - boolean ascending = resolveAscending(sortBy, sortDirection); - Limit fetchLimit = Limit.of(limit + 1); // LIMIT + 1 만큼 fetch 하여 다음 페이지 여부를 확인하기 위함 - - List rows = reviewRepository.findPageByContentId( - contentId, cursor, idAfter, fetchLimit, sortBy, ascending); - + public CursorResponse getReviews(ReviewGetRequest request) { + UUID contentId = request.contentId(); + String cursor = request.cursor(); + 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; // 다음 페이지 여부 - // 다음 페이지가 있으면 row에서 limit 만큼 fetch - // 다음 페이지 없으면 row 다 fetch + 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); // 페이지의 마지막 item - nextCursor = SORT_BY_RATING.equals(sortBy) - ? last.getRating().toString() // 평점 기준일 때 마지막 item의 평점을 cursor로 - : last.getCreatedAt().toString(); // createdAt - nextIdAfter = last.getId().toString(); // 보조 커서는 id + 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); - // 페이지의 authorId를 한 번에 모아서 배치 조회 (N+1 방지) - Map userMap = userRepository.findAllById( - page.stream().map(Review::getAuthorId).toList() - ).stream() - .collect(Collectors.toMap(User::getId, u -> u)); - List data = page.stream() - .map(review -> reviewMapper.toDto(review, userMap.get(review.getAuthorId()))) + .map(reviewMapper::toDto) .toList(); - return new CursorResponse<>(data, nextCursor, nextIdAfter, hasNext, totalCount, sortBy, sortDirection); + 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_IdAndAuthorId(request.contentId(), authorId)) { - throw new ReviewAlreadyExistsException(); - } - Review review = Review.of(content, authorId, request.text(), request.rating()); - Review saved; - try { - saved = reviewRepository.saveAndFlush(review); - } catch (DataIntegrityViolationException e) { - log.warn("리뷰 중복 저장 시도 (race condition): contentId={}, authorId={}", request.contentId(), authorId); + if (reviewRepository.existsByContent_IdAndAuthor_Id(request.contentId(), authorId)) { throw new ReviewAlreadyExistsException(); } - log.info("리뷰 생성 완료: reviewId={}, contentId={}, authorId={}", saved.getId(), saved.getContentId(), authorId); - User user = userRepository.findById(saved.getAuthorId()).orElseThrow(() -> new UserNotFoundException(authorId)); + 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, user); + return reviewMapper.toDto(saved); } - // 리뷰 수정 @Transactional public ReviewResponse updateReview(UUID reviewId, UUID authorId, ReviewUpdateRequest request) { - Review review = reviewRepository.findById(reviewId) + Review review = reviewRepository.findByIdWithAuthor(reviewId) .orElseThrow(() -> new ReviewNotFoundException(reviewId)); if (!review.getAuthorId().equals(authorId)) { log.warn("리뷰 수정 권한 없음: reviewId={}, requesterId={}", reviewId, authorId); @@ -126,15 +126,12 @@ public ReviewResponse updateReview(UUID reviewId, UUID authorId, ReviewUpdateReq } review.update(request.text(), request.rating()); log.info("리뷰 수정 완료: reviewId={}, authorId={}", reviewId, authorId); - User author = userRepository.findById(authorId) - .orElseThrow(() -> new UserNotFoundException(authorId)); - return reviewMapper.toDto(review, author); + return reviewMapper.toDto(review); } - // 리뷰 삭제 @Transactional public void deleteReview(UUID reviewId, UUID authorId) { - Review review = reviewRepository.findById(reviewId) + Review review = reviewRepository.findByIdWithAuthor(reviewId) .orElseThrow(() -> new ReviewNotFoundException(reviewId)); if (!review.getAuthorId().equals(authorId)) { log.warn("리뷰 삭제 권한 없음: reviewId={}, requesterId={}", reviewId, authorId); @@ -144,17 +141,25 @@ public void deleteReview(UUID reviewId, UUID authorId) { log.info("리뷰 삭제 완료: reviewId={}, authorId={}", reviewId, authorId); } - // SortBy, SortDirection을 검증하고 ASC/DESC 여부를 정하는 헬퍼 메서드 - private boolean resolveAscending(String sortBy, String sortDirection) { - if (!SORT_BY_CREATED_AT.equals(sortBy) && !SORT_BY_RATING.equals(sortBy)) { - throw new InvalidReviewSortByException(sortBy); // 평점 순, createdAt 순도 아닌 이상한게 뭐가 들어왔어 + private void validateCursorIdAfterPair(String cursor, UUID idAfter) { + if ((cursor != null && idAfter == null) || (cursor == null && idAfter != null)) { + throw new CursorIdAfterNotTogetherException(); } - if (SORT_ASCENDING.equalsIgnoreCase(sortDirection)) { - return true; + } + + private Instant parseInstantCursor(String cursor) { + try { + return Instant.parse(cursor); + } catch (DateTimeParseException e) { + throw new InvalidCursorException(); } - if (SORT_DESCENDING.equalsIgnoreCase(sortDirection)) { - return false; + } + + private Double parseDoubleCursor(String cursor) { + try { + return Double.parseDouble(cursor); + } catch (NumberFormatException e) { + throw new InvalidCursorException(); } - throw new InvalidSortDirectionException(sortDirection); } } From c97032ec12636edac78c66058d82f6f0545e4937 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 01:10:23 +0900 Subject: [PATCH 28/32] =?UTF-8?q?Refactor:=20Review=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20dto=EC=97=90=20@NotNull=20=EC=96=B4=EB=85=B8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=A0=9C=EA=B1=B0=20-=20null=EA=B0=92?= =?UTF-8?q?=EC=9D=B4=20=EB=93=A4=EC=96=B4=EC=98=A4=EB=A9=B4=20=EC=84=9C?= =?UTF-8?q?=EB=B9=84=EC=8A=A4=20=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20def?= =?UTF-8?q?ault=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=8C=80=EC=B2=B4?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../codeit/team5/mopl/review/dto/request/ReviewGetRequest.java | 2 -- 1 file changed, 2 deletions(-) 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 index acd95654..1445e20b 100644 --- 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 @@ -19,10 +19,8 @@ public record ReviewGetRequest( @Max(100) Integer limit, - @NotNull Sort.Direction sortDirection, - @NotNull ReviewSortBy sortBy ) { } From 45090554c08056c566aaecaca75163c1459cbc0a Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 01:11:26 +0900 Subject: [PATCH 29/32] =?UTF-8?q?Test:=20=EB=B0=94=EB=80=90=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=9C=20Review=EB=8A=94=20User=EB=A5=BC=20?= =?UTF-8?q?=EC=A7=81=EC=A0=91=20=EC=B0=B8=EC=A1=B0=ED=95=98=EB=AF=80?= =?UTF-8?q?=EB=A1=9C=20author.getId()=20->=20author=20=ED=98=95=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20ReviewSortBy,=20S?= =?UTF-8?q?ort.Direction=EC=9D=84=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EC=97=90=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ReviewControllerIntegrationTest.java | 37 ++-- .../controller/ReviewControllerTest.java | 15 +- .../repository/ReviewRepositoryTest.java | 180 ++++++++---------- .../review/service/ReviewServiceTest.java | 141 +++++--------- 4 files changed, 145 insertions(+), 228 deletions(-) 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 index f26758f7..a39fe315 100644 --- a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java @@ -23,7 +23,6 @@ import com.codeit.team5.mopl.user.entity.User; import com.codeit.team5.mopl.user.repository.UserRepository; import com.fasterxml.jackson.databind.ObjectMapper; -import java.time.Instant; import java.util.UUID; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -69,8 +68,8 @@ private Content persistContent(String title) { Content.createByAdmin(ContentType.MOVIE, title, null)); } - private Review persistReview(Content content, UUID authorId, String text, double rating) { - return reviewRepository.saveAndFlush(Review.of(content, authorId, text, rating)); + 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) { @@ -87,7 +86,7 @@ void getReviews_success() throws Exception { // given User author = persistUser("list@example.com"); Content content = persistContent("영화1"); - Review saved = persistReview(content, author.getId(), "재밌어요", 4.5); + Review saved = persistReview(content, author, "재밌어요", 4.5); // when & then mockMvc.perform(get("/api/reviews") @@ -110,8 +109,8 @@ void getReviews_filtersByContentId() throws Exception { User author = persistUser("filter@example.com"); Content myContent = persistContent("내 영화"); Content otherContent = persistContent("다른 영화"); - persistReview(myContent, author.getId(), "내 리뷰", 4.0); - persistReview(otherContent, author.getId(), "다른 리뷰", 3.0); + persistReview(myContent, author, "내 리뷰", 4.0); + persistReview(otherContent, author, "다른 리뷰", 3.0); // when & then mockMvc.perform(get("/api/reviews") @@ -130,17 +129,17 @@ void getReviews_cursorPagination() throws Exception { User author2 = persistUser("page2@example.com"); User author3 = persistUser("page3@example.com"); Content content = persistContent("영화2"); - persistReview(content, author1.getId(), "리뷰1", 5.0); - persistReview(content, author2.getId(), "리뷰2", 4.0); - persistReview(content, author3.getId(), "리뷰3", 3.0); + 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", "DESCENDING")) + .param("sortBy", "CREATED_AT") + .param("sortDirection", "DESC")) .andExpect(status().isOk()) .andExpect(jsonPath("$.hasNext").value(true)) .andExpect(jsonPath("$.data.length()").value(2)) @@ -160,8 +159,8 @@ void getReviews_cursorPagination() throws Exception { .param("cursor", nextCursor) .param("idAfter", nextIdAfter) .param("limit", "2") - .param("sortBy", "createdAt") - .param("sortDirection", "DESCENDING")) + .param("sortBy", "CREATED_AT") + .param("sortDirection", "DESC")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(1)) .andExpect(jsonPath("$.hasNext").value(false)); @@ -198,7 +197,7 @@ void createReview_success() throws Exception { .andExpect(jsonPath("$.text").value("최고예요")) .andExpect(jsonPath("$.rating").value(5.0)); - assertThat(reviewRepository.existsByContent_IdAndAuthorId(content.getId(), author.getId())).isTrue(); + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isTrue(); } @Test @@ -224,7 +223,7 @@ void createReview_duplicate_returns409() throws Exception { // given User author = persistUser("dup@example.com"); Content content = persistContent("영화4"); - persistReview(content, author.getId(), "첫 리뷰", 4.0); + persistReview(content, author, "첫 리뷰", 4.0); ReviewCreateRequest request = new ReviewCreateRequest(content.getId(), "두 번째 리뷰", 3.0); // when & then @@ -245,7 +244,7 @@ void updateReview_success() throws Exception { // given User author = persistUser("update@example.com"); Content content = persistContent("영화5"); - Review review = persistReview(content, author.getId(), "원래 내용", 3.0); + Review review = persistReview(content, author, "원래 내용", 3.0); ReviewUpdateRequest request = new ReviewUpdateRequest("수정된 내용", 4.0); // when & then @@ -266,7 +265,7 @@ void updateReview_forbidden_returns403() throws Exception { User owner = persistUser("owner@example.com"); User attacker = persistUser("attacker@example.com"); Content content = persistContent("영화6"); - Review review = persistReview(content, owner.getId(), "원래 내용", 3.0); + Review review = persistReview(content, owner, "원래 내용", 3.0); ReviewUpdateRequest request = new ReviewUpdateRequest("악의적 수정", 1.0); // when & then @@ -304,7 +303,7 @@ void deleteReview_success() throws Exception { // given User author = persistUser("delete@example.com"); Content content = persistContent("영화7"); - Review review = persistReview(content, author.getId(), "삭제할 리뷰", 3.0); + Review review = persistReview(content, author, "삭제할 리뷰", 3.0); // when & then mockMvc.perform(delete("/api/reviews/{reviewId}", review.getId()) @@ -322,7 +321,7 @@ void deleteReview_forbidden_returns403() throws Exception { User owner = persistUser("delowner@example.com"); User attacker = persistUser("delattacker@example.com"); Content content = persistContent("영화8"); - Review review = persistReview(content, owner.getId(), "남의 리뷰", 4.0); + Review review = persistReview(content, owner, "남의 리뷰", 4.0); // when & then mockMvc.perform(delete("/api/reviews/{reviewId}", review.getId()) 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 index 1c1a7b79..0b110a1c 100644 --- a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java @@ -33,6 +33,7 @@ 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; @@ -128,8 +129,7 @@ void getReviews_success() throws Exception { CursorResponse response = new CursorResponse<>( List.of(review), null, null, false, 1L, "createdAt", "DESCENDING"); - given(reviewService.getReviews( - eq(contentId), eq(null), eq(null), eq(20), eq("DESCENDING"), eq("createdAt"))) + given(reviewService.getReviews(any(ReviewGetRequest.class))) .willReturn(response); // when & then @@ -159,8 +159,7 @@ void getReviews_withCursorAndSort_success() throws Exception { CursorResponse response = new CursorResponse<>( List.of(), null, null, false, 0L, "rating", "ASCENDING"); - given(reviewService.getReviews( - eq(contentId), eq(cursor), eq(idAfter), eq(10), eq("ASCENDING"), eq("rating"))) + given(reviewService.getReviews(any(ReviewGetRequest.class))) .willReturn(response); // when & then @@ -170,8 +169,8 @@ void getReviews_withCursorAndSort_success() throws Exception { .param("cursor", cursor) .param("idAfter", idAfter.toString()) .param("limit", "10") - .param("sortDirection", "ASCENDING") - .param("sortBy", "rating")) + .param("sortDirection", "ASC") + .param("sortBy", "RATING")) .andExpect(status().isOk()) .andExpect(jsonPath("$.hasNext").value(false)) .andExpect(jsonPath("$.data").isEmpty()); @@ -187,7 +186,7 @@ void getReviews_limitZero_returns400() throws Exception { .param("limit", "0")) .andExpect(status().isBadRequest()); - verify(reviewService, never()).getReviews(any(), any(), any(), any(int.class), any(), any()); + verify(reviewService, never()).getReviews(any(ReviewGetRequest.class)); } @Test @@ -200,7 +199,7 @@ void getReviews_limitOver100_returns400() throws Exception { .param("limit", "101")) .andExpect(status().isBadRequest()); - verify(reviewService, never()).getReviews(any(), any(), any(), any(int.class), any(), any()); + verify(reviewService, never()).getReviews(any(ReviewGetRequest.class)); } // ===== POST /api/reviews ===== 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 index d4f41539..c2d7c855 100644 --- a/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/repository/ReviewRepositoryTest.java @@ -8,7 +8,6 @@ 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.notification.exception.CursorIdAfterNotTogetherException; import com.codeit.team5.mopl.review.entity.Review; import com.codeit.team5.mopl.user.entity.User; import jakarta.persistence.EntityManager; @@ -17,7 +16,6 @@ import java.time.ZoneOffset; import java.util.Comparator; 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; @@ -26,6 +24,7 @@ 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 @@ -40,7 +39,6 @@ class ReviewRepositoryTest { @Autowired private EntityManager entityManager; - // em으로 테스트용 user 객체 persist 후 flush 하는 내부 메서드 private User persistUser(String email) { User user = User.create(email, "password", "테스터"); entityManager.persist(user); @@ -48,7 +46,6 @@ private User persistUser(String email) { return user; } - // title 받고 content를 생성 및 persist & flush private Content persistContent(String title) { Content content = Content.createByAdmin(ContentType.MOVIE, title, null); entityManager.persist(content); @@ -56,19 +53,17 @@ private Content persistContent(String title) { return content; } - // 컨텐츠, userId, text, 평점을 토대로 리뷰 객체를 생성 및 persist & flush - private Review persistReview(Content content, UUID authorId, String text, double rating) { - Review review = Review.of(content, authorId, text, rating); + 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; } - // 리뷰의 createdAt을 세팅 - private void setCreatedAt(UUID reviewId, Instant createdAt) { + 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)) // DB에서는 OffsetDataTime으로 다루는 것이 호환성이 좋아 이렇게 변환한다고 합니다. + .setParameter("ts", OffsetDateTime.ofInstant(createdAt, ZoneOffset.UTC)) .setParameter("id", reviewId) .executeUpdate(); } @@ -81,7 +76,7 @@ void save_success() { Content content = persistContent("영화1"); // when - Review saved = persistReview(content, author.getId(), "재밌어요", 4.5); + Review saved = persistReview(content, author, "재밌어요", 4.5); entityManager.clear(); // then @@ -99,36 +94,36 @@ void save_duplicateReview_throwsException() { // given User author = persistUser("dup@example.com"); Content content = persistContent("영화2"); - reviewRepository.saveAndFlush(Review.of(content, author.getId(), "첫 리뷰", 4.0)); + reviewRepository.saveAndFlush(Review.of(content, author, "첫 리뷰", 4.0)); // when & then assertThatThrownBy(() -> - reviewRepository.saveAndFlush(Review.of(content, author.getId(), "중복 리뷰", 3.0))) - .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); // DB 제약으로 인한 DataIntegrity 예외 발생 + reviewRepository.saveAndFlush(Review.of(content, author, "중복 리뷰", 3.0))) + .isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); } @Test - @DisplayName("existsByContent_IdAndAuthorId: 리뷰가 존재하면 true를 반환한다") - void existsByContent_IdAndAuthorId_exists() { + @DisplayName("existsByContent_IdAndAuthor_Id: 리뷰가 존재하면 true를 반환한다") + void existsByContent_IdAndAuthor_Id_exists() { // given User author = persistUser("exists@example.com"); Content content = persistContent("영화3"); - persistReview(content, author.getId(), "리뷰", 4.0); + persistReview(content, author, "리뷰", 4.0); entityManager.clear(); // when & then - assertThat(reviewRepository.existsByContent_IdAndAuthorId(content.getId(), author.getId())).isTrue(); + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isTrue(); } @Test - @DisplayName("existsByContent_IdAndAuthorId: 리뷰가 없으면 false를 반환한다") - void existsByContent_IdAndAuthorId_notExists() { + @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_IdAndAuthorId(content.getId(), author.getId())).isFalse(); + assertThat(reviewRepository.existsByContent_IdAndAuthor_Id(content.getId(), author.getId())).isFalse(); } @Test @@ -140,9 +135,9 @@ void countByContent_Id_success() { Content content = persistContent("영화5"); Content otherContent = persistContent("영화6"); - persistReview(content, author1.getId(), "리뷰1", 4.0); - persistReview(content, author2.getId(), "리뷰2", 3.5); - persistReview(otherContent, author1.getId(), "다른 콘텐츠 리뷰", 5.0); + persistReview(content, author1, "리뷰1", 4.0); + persistReview(content, author2, "리뷰2", 3.5); + persistReview(otherContent, author1, "다른 콘텐츠 리뷰", 5.0); entityManager.clear(); // when & then @@ -151,7 +146,7 @@ void countByContent_Id_success() { @Test @DisplayName("createdAt 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") - void findPageByContentId_createdAtDesc_pagination() { + void findPageByContentIdSortByCreatedAt_desc_pagination() { // given User author1 = persistUser("page-desc1@example.com"); User author2 = persistUser("page-desc2@example.com"); @@ -161,58 +156,55 @@ void findPageByContentId_createdAtDesc_pagination() { Content content = persistContent("영화7"); Content otherContent = persistContent("영화8"); - Review r1 = persistReview(content, author1.getId(), "r1", 4.0); + Review r1 = persistReview(content, author1, "r1", 4.0); setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); - Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + Review r2 = persistReview(content, author2, "r2", 3.5); setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); - Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + Review r3 = persistReview(content, author3, "r3", 5.0); setCreatedAt(r3.getId(), Instant.parse("2026-01-01T00:03:00Z")); - Review r4 = persistReview(content, author4.getId(), "r4", 2.0); + Review r4 = persistReview(content, author4, "r4", 2.0); setCreatedAt(r4.getId(), Instant.parse("2026-01-01T00:04:00Z")); - Review r5 = persistReview(content, author5.getId(), "r5", 1.5); + Review r5 = persistReview(content, author5, "r5", 1.5); setCreatedAt(r5.getId(), Instant.parse("2026-01-01T00:05:00Z")); - persistReview(otherContent, author1.getId(), "other", 4.0); + persistReview(otherContent, author1, "other", 4.0); entityManager.flush(); entityManager.clear(); // when - // 커서,idAfter가 null이므로 첫페이지 fetch - List firstPage = reviewRepository.findPageByContentId( - content.getId(), null, null, Limit.of(2), "createdAt", false); - Review cursor = firstPage.get(firstPage.size() - 1); // 마지막 요소가 cursor - // 이어서 두번째 페이지 fetch - List secondPage = reviewRepository.findPageByContentId( - content.getId(), cursor.getCreatedAt().toString(), cursor.getId(), Limit.of(2), "createdAt", false); + 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); - // 두번째 페이지 id가 첫번째 페이지에 포함 X -> 페이지 겹침 없음 assertThat(secondPage).extracting(Review::getId) .doesNotContainAnyElementsOf(firstPage.stream().map(Review::getId).toList()); } @Test @DisplayName("createdAt 내림차순 조회는 최신 리뷰가 먼저 반환된다") - void findPageByContentId_createdAtDesc_ordering() { + 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.getId(), "r1", 4.0); + Review r1 = persistReview(content, author1, "r1", 4.0); setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); - Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + Review r2 = persistReview(content, author2, "r2", 3.5); setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); - Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + 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.findPageByContentId( - content.getId(), null, null, Limit.of(10), "createdAt", false); + List all = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), null, null, Limit.of(10), Sort.Direction.DESC); // then assertThat(all).hasSize(3); @@ -220,26 +212,26 @@ void findPageByContentId_createdAtDesc_ordering() { } @Test - @DisplayName("createdAt 올림차순 조회는 오래된 리뷰가 먼저 반환된다") - void findPageByContentId_createdAtAsc_ordering() { + @DisplayName("createdAt 오름차순 조회는 오래된 리뷰가 먼저 반환된다") + void findPageByContentIdSortByCreatedAt_asc_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"); + 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.getId(), "r1", 4.0); + Review r1 = persistReview(content, author1, "r1", 4.0); setCreatedAt(r1.getId(), Instant.parse("2026-01-01T00:01:00Z")); - Review r2 = persistReview(content, author2.getId(), "r2", 3.5); + Review r2 = persistReview(content, author2, "r2", 3.5); setCreatedAt(r2.getId(), Instant.parse("2026-01-01T00:02:00Z")); - Review r3 = persistReview(content, author3.getId(), "r3", 5.0); + 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.findPageByContentId( - content.getId(), null, null, Limit.of(10), "createdAt", true); + List all = reviewRepository.findPageByContentIdSortByCreatedAt( + content.getId(), null, null, Limit.of(10), Sort.Direction.ASC); // then assertThat(all).hasSize(3); @@ -248,27 +240,27 @@ void findPageByContentId_createdAtAsc_ordering() { @Test @DisplayName("rating 내림차순 커서 페이지네이션: 두 페이지가 겹치지 않고 이어진다") - void findPageByContentId_ratingDesc_pagination() { + 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("영화10"); + Content content = persistContent("영화11"); - persistReview(content, author1.getId(), "r1", 5.0); - persistReview(content, author2.getId(), "r2", 4.0); - persistReview(content, author3.getId(), "r3", 3.0); - persistReview(content, author4.getId(), "r4", 2.0); + 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.findPageByContentId( - content.getId(), null, null, Limit.of(2), "rating", false); + List firstPage = reviewRepository.findPageByContentIdSortByRating( + content.getId(), null, null, Limit.of(2), Sort.Direction.DESC); Review cursor = firstPage.get(firstPage.size() - 1); - List secondPage = reviewRepository.findPageByContentId( - content.getId(), cursor.getRating().toString(), cursor.getId(), Limit.of(2), "rating", false); + List secondPage = reviewRepository.findPageByContentIdSortByRating( + content.getId(), cursor.getRating(), cursor.getId(), Limit.of(2), Sort.Direction.DESC); // then assertThat(firstPage).hasSize(2); @@ -279,22 +271,22 @@ void findPageByContentId_ratingDesc_pagination() { @Test @DisplayName("rating 내림차순 조회는 높은 평점이 먼저 반환된다") - void findPageByContentId_ratingDesc_ordering() { + 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("영화11"); + Content content = persistContent("영화12"); - persistReview(content, author1.getId(), "r1", 3.0); - persistReview(content, author2.getId(), "r2", 5.0); // 먼저 반환될 리뷰 - persistReview(content, author3.getId(), "r3", 1.0); // 제일 뒤에 반환될 리뷰 + 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.findPageByContentId( - content.getId(), null, null, Limit.of(10), "rating", false); // 내림차순 + List all = reviewRepository.findPageByContentIdSortByRating( + content.getId(), null, null, Limit.of(10), Sort.Direction.DESC); // then assertThat(all).hasSize(3); @@ -302,44 +294,26 @@ void findPageByContentId_ratingDesc_ordering() { } @Test - @DisplayName("rating 올림차순 조회는 낮은 평점이 먼저 반환된다") - void findPageByContentId_ratingAsc_ordering() { + @DisplayName("rating 오름차순 조회는 낮은 평점이 먼저 반환된다") + void findPageByContentIdSortByRating_asc_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("영화11"); - - persistReview(content, author1.getId(), "r1", 3.0); - persistReview(content, author2.getId(), "r2", 5.0); // 제일 뒤에 위치한 리뷰 - persistReview(content, author3.getId(), "r3", 1.0); // 제일 앞에 위치한 리뷰 + 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.findPageByContentId( - content.getId(), null, null, Limit.of(10), "rating", true); // 올림차순 + 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)); } - - @Test - @DisplayName("cursor와 idAfter 중 하나만 주어지면 예외가 발생한다") - void findPageByContentId_cursorIdAfterNotTogether_exception() { - // given - UUID contentId = UUID.randomUUID(); - - // when & then - // 보조커서(idAfter)가 null이면 예외 발생 - assertThatThrownBy(() -> reviewRepository.findPageByContentId( - contentId, Instant.now().toString(), null, Limit.of(2), "createdAt", false)) - .isInstanceOf(CursorIdAfterNotTogetherException.class); - - // 주 커서 (cursor)가 null이면 예외 발생 - assertThatThrownBy(() -> reviewRepository.findPageByContentId( - contentId, null, UUID.randomUUID(), Limit.of(2), "createdAt", false)) - .isInstanceOf(CursorIdAfterNotTogetherException.class); - } } 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 index 5e12a0fd..13d05641 100644 --- a/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/service/ReviewServiceTest.java @@ -3,7 +3,6 @@ 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.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; @@ -12,18 +11,19 @@ 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.global.exception.InvalidSortDirectionException; +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.InvalidReviewSortByException; 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; @@ -35,8 +35,8 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Sort; @ExtendWith(MockitoExtension.class) class ReviewServiceTest { @@ -61,29 +61,23 @@ class ReviewServiceTest { void getReviews_hasNext_createdAtCursor() { // given UUID contentId = UUID.randomUUID(); - UUID authorId = 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 r1 = mock(Review.class); Review r2 = mock(Review.class); - given(r0.getAuthorId()).willReturn(authorId); - given(r1.getAuthorId()).willReturn(authorId); - given(r1.getCreatedAt()).willReturn(lastCreatedAt); // 해당 리뷰에 cursor&idAfter 삽입 + given(r1.getCreatedAt()).willReturn(lastCreatedAt); given(r1.getId()).willReturn(lastId); - given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", false)) + 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); - User user = mock(User.class); - given(user.getId()).willReturn(authorId); - given(userRepository.findAllById(anyList())).willReturn(List.of(user)); - given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); // when - CursorResponse result = reviewService.getReviews( - contentId, null, null, 2, "DESCENDING", "createdAt"); + CursorResponse result = reviewService.getReviews(request); // then assertThat(result.hasNext()).isTrue(); @@ -97,29 +91,23 @@ void getReviews_hasNext_createdAtCursor() { void getReviews_hasNext_ratingCursor() { // given UUID contentId = UUID.randomUUID(); - UUID authorId = 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(r0.getAuthorId()).willReturn(authorId); - given(r1.getAuthorId()).willReturn(authorId); given(r1.getRating()).willReturn(lastRating); given(r1.getId()).willReturn(lastId); - given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "rating", false)) + 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); - User user = mock(User.class); - given(user.getId()).willReturn(authorId); - given(userRepository.findAllById(anyList())).willReturn(List.of(user)); - given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); // when - CursorResponse result = reviewService.getReviews( - contentId, null, null, 2, "DESCENDING", "rating"); + CursorResponse result = reviewService.getReviews(request); // then assertThat(result.hasNext()).isTrue(); @@ -132,21 +120,16 @@ void getReviews_hasNext_ratingCursor() { void getReviews_lastPage() { // given UUID contentId = UUID.randomUUID(); - UUID authorId = UUID.randomUUID(); Review r0 = mock(Review.class); - given(r0.getAuthorId()).willReturn(authorId); - given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", false)) + 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); - User user = mock(User.class); - given(user.getId()).willReturn(authorId); - given(userRepository.findAllById(anyList())).willReturn(List.of(user)); - given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); // when - CursorResponse result = reviewService.getReviews( - contentId, null, null, 2, "DESCENDING", "createdAt"); + CursorResponse result = reviewService.getReviews(request); // then assertThat(result.hasNext()).isFalse(); @@ -155,54 +138,25 @@ void getReviews_lastPage() { } @Test - @DisplayName("sortDirection이 ASCENDING이면 ascending=true로 리포지토리를 호출한다") + @DisplayName("sortDirection이 ASCENDING이면 ASC로 리포지토리를 호출한다") void getReviews_ascending() { // given UUID contentId = UUID.randomUUID(); - UUID authorId = UUID.randomUUID(); Review r0 = mock(Review.class); - given(r0.getAuthorId()).willReturn(authorId); - given(reviewRepository.findPageByContentId(contentId, null, null, Limit.of(3), "createdAt", true)) + 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); - User user = mock(User.class); - given(user.getId()).willReturn(authorId); - given(userRepository.findAllById(anyList())).willReturn(List.of(user)); - given(reviewMapper.toDto(any(), any())).willReturn(mock(ReviewResponse.class)); + given(reviewMapper.toDto(any(Review.class))).willReturn(mock(ReviewResponse.class)); // when - CursorResponse result = reviewService.getReviews( - contentId, null, null, 2, "ASCENDING", "createdAt"); + CursorResponse result = reviewService.getReviews(request); // then assertThat(result.hasNext()).isFalse(); } - @Test - @DisplayName("지원하지 않는 sortBy면 예외가 발생한다") - void getReviews_invalidSortBy_exception() { - // given - UUID contentId = UUID.randomUUID(); - - // when & then - assertThatThrownBy(() -> reviewService.getReviews( - contentId, null, null, 2, "DESCENDING", "title")) - .isInstanceOf(InvalidReviewSortByException.class); - } - - @Test - @DisplayName("지원하지 않는 sortDirection이면 예외가 발생한다") - void getReviews_invalidSortDirection_exception() { - // given - UUID contentId = UUID.randomUUID(); - - // when & then - assertThatThrownBy(() -> reviewService.getReviews( - contentId, null, null, 2, "WHATEVER", "createdAt")) - .isInstanceOf(InvalidSortDirectionException.class); - } - @Test @DisplayName("리뷰 생성에 성공한다") void ofReview_success() { @@ -212,26 +166,24 @@ void ofReview_success() { ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); Content content = mock(Content.class); - Review saved = mock(Review.class); User user = mock(User.class); + Review saved = mock(Review.class); ReviewResponse response = mock(ReviewResponse.class); - // reviewService.createReview 내에서 실행되는 타 계층 반환값들 세팅 given(contentRepository.findById(contentId)).willReturn(Optional.of(content)); - given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); // 리뷰 중복 작성 여부 - given(reviewRepository.saveAndFlush(any())).willReturn(saved); - given(saved.getAuthorId()).willReturn(authorId); + 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(userRepository.findById(authorId)).willReturn(Optional.of(user)); - given(reviewMapper.toDto(saved, user)).willReturn(response); + given(reviewMapper.toDto(saved)).willReturn(response); // when ReviewResponse result = reviewService.createReview(authorId, request); // then assertThat(result).isEqualTo(response); - verify(reviewRepository).saveAndFlush(any()); + verify(reviewRepository).save(any()); } @Test @@ -258,7 +210,8 @@ void ofReview_alreadyExists_exception() { ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); given(contentRepository.findById(contentId)).willReturn(Optional.of(mock(Content.class))); - given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(true); + 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)) @@ -266,24 +219,19 @@ void ofReview_alreadyExists_exception() { } @Test - @DisplayName("race condition으로 DB unique 제약 위반 시 ReviewAlreadyExistsException을 던진다") - void ofReview_raceConditionUniqueViolation_exception() { + @DisplayName("존재하지 않는 유저가 리뷰 생성 시 예외가 발생한다") + void ofReview_userNotFound_exception() { // given UUID authorId = UUID.randomUUID(); UUID contentId = UUID.randomUUID(); ReviewCreateRequest request = new ReviewCreateRequest(contentId, "좋아요", 4.5); - // createReview 호출 시 호출되는 메서드들 반환 값 세팅 given(contentRepository.findById(contentId)).willReturn(Optional.of(mock(Content.class))); - given(reviewRepository.existsByContent_IdAndAuthorId(contentId, authorId)).willReturn(false); - - DataIntegrityViolationException ex = new DataIntegrityViolationException( - "duplicate key value violates unique constraint \"uk_reviews_content_id_user_id\""); - given(reviewRepository.saveAndFlush(any())).willThrow(ex); + given(userRepository.findById(authorId)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> reviewService.createReview(authorId, request)) - .isInstanceOf(ReviewAlreadyExistsException.class); + .isInstanceOf(UserNotFoundException.class); } @Test @@ -295,13 +243,11 @@ void updateReview_success() { ReviewUpdateRequest request = new ReviewUpdateRequest("수정된 내용", 3.0); Review review = mock(Review.class); - User author = mock(User.class); ReviewResponse response = mock(ReviewResponse.class); - given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); given(review.getAuthorId()).willReturn(authorId); - given(userRepository.findById(authorId)).willReturn(Optional.of(author)); - given(reviewMapper.toDto(review, author)).willReturn(response); + given(reviewMapper.toDto(review)).willReturn(response); // when ReviewResponse result = reviewService.updateReview(reviewId, authorId, request); @@ -318,8 +264,7 @@ void updateReview_notFound_exception() { UUID reviewId = UUID.randomUUID(); UUID authorId = UUID.randomUUID(); - // 존재하지 않는 리뷰 -> Optional<> 반환 - given(reviewRepository.findById(reviewId)).willReturn(Optional.empty()); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> reviewService.updateReview( @@ -336,7 +281,7 @@ void updateReview_forbidden_exception() { UUID otherId = UUID.randomUUID(); Review review = mock(Review.class); - given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); given(review.getAuthorId()).willReturn(otherId); // when & then @@ -353,7 +298,7 @@ void deleteReview_success() { UUID authorId = UUID.randomUUID(); Review review = mock(Review.class); - given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); given(review.getAuthorId()).willReturn(authorId); // when @@ -369,7 +314,7 @@ void deleteReview_notFound_exception() { // given UUID reviewId = UUID.randomUUID(); - given(reviewRepository.findById(reviewId)).willReturn(Optional.empty()); + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.empty()); // when & then assertThatThrownBy(() -> reviewService.deleteReview(reviewId, UUID.randomUUID())) @@ -385,8 +330,8 @@ void deleteReview_forbidden_exception() { UUID otherId = UUID.randomUUID(); Review review = mock(Review.class); - given(reviewRepository.findById(reviewId)).willReturn(Optional.of(review)); - given(review.getAuthorId()).willReturn(otherId); // review.authorId = other + given(reviewRepository.findByIdWithAuthor(reviewId)).willReturn(Optional.of(review)); + given(review.getAuthorId()).willReturn(otherId); // when & then assertThatThrownBy(() -> reviewService.deleteReview(reviewId, authorId)) From 6b6c3675388ac5e3d65db857d4ce9b91ba04d753 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 02:07:33 +0900 Subject: [PATCH 30/32] =?UTF-8?q?Feat:=20WebConfig=EC=97=90=20ReviewSortBy?= =?UTF-8?q?=20=EC=BB=A8=EB=B2=84=ED=84=B0=20=EB=93=B1=EB=A1=9D=20-=20?= =?UTF-8?q?=EC=9D=B4=EC=A0=9C=20=EC=9D=91=EB=8B=B5=EC=9D=98=20sortBy:=20cr?= =?UTF-8?q?eatedAt=EC=9D=84=20=ED=81=B4=E3=84=B9=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EC=96=B8=ED=8A=B8=EA=B0=80=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=EC=9A=94=EC=B2=AD=EC=97=90=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=B4=EB=8F=84=20400=EC=9D=B4=20=EB=82=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EA=B3=A0=20=EC=A0=95=EC=83=81=EC=A0=81?= =?UTF-8?q?=EC=9D=B8=20=EB=B0=94=EC=9D=B8=EB=94=A9=EC=9D=B4=20=EB=90=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/codeit/team5/mopl/config/WebConfig.java | 2 ++ .../review/controller/ReviewControllerIntegrationTest.java | 4 ++-- .../team5/mopl/review/controller/ReviewControllerTest.java | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) 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/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java index a39fe315..2eb372cc 100644 --- a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerIntegrationTest.java @@ -138,7 +138,7 @@ void getReviews_cursorPagination() throws Exception { .with(authentication(authOf(author1.getId(), "page1@example.com"))) .param("contentId", content.getId().toString()) .param("limit", "2") - .param("sortBy", "CREATED_AT") + .param("sortBy", "createdAt") .param("sortDirection", "DESC")) .andExpect(status().isOk()) .andExpect(jsonPath("$.hasNext").value(true)) @@ -159,7 +159,7 @@ void getReviews_cursorPagination() throws Exception { .param("cursor", nextCursor) .param("idAfter", nextIdAfter) .param("limit", "2") - .param("sortBy", "CREATED_AT") + .param("sortBy", "createdAt") .param("sortDirection", "DESC")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.length()").value(1)) 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 index 0b110a1c..ede28969 100644 --- a/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/codeit/team5/mopl/review/controller/ReviewControllerTest.java @@ -170,7 +170,7 @@ void getReviews_withCursorAndSort_success() throws Exception { .param("idAfter", idAfter.toString()) .param("limit", "10") .param("sortDirection", "ASC") - .param("sortBy", "RATING")) + .param("sortBy", "rating")) .andExpect(status().isOk()) .andExpect(jsonPath("$.hasNext").value(false)) .andExpect(jsonPath("$.data").isEmpty()); From 6beea7135b55fab304efa188ac73152726361d52 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 03:19:17 +0900 Subject: [PATCH 31/32] =?UTF-8?q?Fix:=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B3=84=EC=B8=B5=EC=97=90=EC=84=9C=20cursor=EA=B0=80=20blank?= =?UTF-8?q?=20=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EB=93=A4=EC=96=B4=EC=98=A4?= =?UTF-8?q?=EB=A9=B4=20null=EB=A1=9C=20=EC=B2=98=EB=A6=AC=20-=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=EC=97=90=EB=8A=94=20cursor=EA=B0=80=20blank=20?= =?UTF-8?q?=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A1=9C=20=EB=93=A4?= =?UTF-8?q?=EC=96=B4=EC=98=A4=EB=A9=B4=20=ED=95=B4=EB=8B=B9=20=EA=B0=92?= =?UTF-8?q?=EC=9D=84=20=EB=B9=88=20=EB=AC=B8=EC=9E=90=EC=97=B4=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B8=EC=8B=9D=ED=95=98=EC=97=AC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EA=B0=80=20=EC=95=88=EB=90=98=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/codeit/team5/mopl/review/service/ReviewService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e64188df..8f8d0bba 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -50,7 +50,7 @@ public class ReviewService { @Transactional(readOnly = true) public CursorResponse getReviews(ReviewGetRequest request) { UUID contentId = request.contentId(); - String cursor = request.cursor(); + 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; From 727413738a3f5a93fe30f2f10ac5fbaebebb4635 Mon Sep 17 00:00:00 2001 From: Seongkyeong Kim Date: Mon, 6 Jul 2026 09:54:48 +0900 Subject: [PATCH 32/32] =?UTF-8?q?Refactor:=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20-=20service=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=EC=97=90=EC=84=9C=20cursor=20=EA=B0=92=20=ED=8C=8C=EC=8B=B1=20?= =?UTF-8?q?=EC=8B=9C=20ReviewSortBy=EC=9D=98=20parse=20=ED=99=9C=EC=9A=A9?= =?UTF-8?q?=20-=20ReviewMapper=EB=8A=94=20review=EB=A7=8C=20=EB=8B=A4?= =?UTF-8?q?=EB=A3=A8=EA=B3=A0=20UserSummaryResponse=EB=8A=94=20uses=20?= =?UTF-8?q?=EC=98=B5=EC=85=98=EC=9D=84=20=ED=99=9C=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=97=AC=20userMapper=20=EC=82=AC=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../team5/mopl/review/mapper/ReviewMapper.java | 10 +++------- .../querydsl/ReviewQueryRepositoryImpl.java | 2 +- .../team5/mopl/review/service/ReviewService.java | 13 ++----------- 3 files changed, 6 insertions(+), 19 deletions(-) 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 index b2e2c0da..1b4624a0 100644 --- a/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java +++ b/src/main/java/com/codeit/team5/mopl/review/mapper/ReviewMapper.java @@ -2,22 +2,18 @@ import com.codeit.team5.mopl.review.dto.response.ReviewResponse; import com.codeit.team5.mopl.review.entity.Review; -import com.codeit.team5.mopl.user.dto.response.UserSummaryResponse; -import com.codeit.team5.mopl.user.entity.User; +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 + unmappedTargetPolicy = ReportingPolicy.ERROR, + uses = {UserMapper.class} ) public interface ReviewMapper { @Mapping(target = "author", source = "author") ReviewResponse toDto(Review review); - - @Mapping(target = "userId", source = "id") - @Mapping(target = "profileImageUrl", source = "profileImage.url") - UserSummaryResponse toAuthor(User user); } 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 index 56d8840c..b0150f5b 100644 --- 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 @@ -19,7 +19,7 @@ public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { private final JPAQueryFactory queryFactory; - QReview r = QReview.review; + private static final QReview r = QReview.review; @Override public List findPageByContentIdSortByRating(UUID contentId, Double cursor, UUID idAfter, 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 index 8f8d0bba..d73d3448 100644 --- a/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java +++ b/src/main/java/com/codeit/team5/mopl/review/service/ReviewService.java @@ -112,7 +112,6 @@ public ReviewResponse createReview(UUID authorId, ReviewCreateRequest request) { 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); } @@ -148,18 +147,10 @@ private void validateCursorIdAfterPair(String cursor, UUID idAfter) { } private Instant parseInstantCursor(String cursor) { - try { - return Instant.parse(cursor); - } catch (DateTimeParseException e) { - throw new InvalidCursorException(); - } + return (Instant) ReviewSortBy.CREATED_AT.parse(cursor); } private Double parseDoubleCursor(String cursor) { - try { - return Double.parseDouble(cursor); - } catch (NumberFormatException e) { - throw new InvalidCursorException(); - } + return (Double) ReviewSortBy.RATING.parse(cursor); } }