From af9003075e0aa6bd7eb600e17702ef8264228dd5 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Fri, 6 Mar 2026 15:43:41 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1,=20=EC=8A=B9=EC=9D=B8,=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=B4=EC=97=AD=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84=20#30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/payment/entity/Payment.java | 109 +++++++++ .../domain/payment/entity/PaymentHistory.java | 75 ++++++ .../domain/payment/entity/PaymentMethod.java | 6 + .../domain/payment/entity/PaymentStatus.java | 9 + .../domain/payment/entity/PgProvider.java | 6 + .../controller/PaymentController.java | 140 +++++++++++ .../dto/request/PaymentCancelRequest.java | 19 ++ .../dto/request/PaymentConfirmRequest.java | 20 ++ .../dto/request/PaymentCreateRequest.java | 25 ++ .../dto/response/PaymentCancelResponse.java | 30 +++ .../dto/response/PaymentConfirmResponse.java | 30 +++ .../dto/response/PaymentCreateResponse.java | 35 +++ .../dto/response/PaymentDeleteResponse.java | 25 ++ .../dto/response/PaymentFailResponse.java | 30 +++ .../dto/response/PaymentResponse.java | 52 ++++ .../repository/PaymentHistoryRepository.java | 13 + .../payment/repository/PaymentRepository.java | 28 +++ .../repository/PaymentRepositoryCustom.java | 20 ++ .../repository/PaymentRepositoryImpl.java | 47 ++++ .../payment/service/PaymentService.java | 228 ++++++++++++++++++ .../global/exception/ErrorCode.java | 10 +- 21 files changed, 956 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/entity/Payment.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentHistory.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentMethod.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentStatus.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PgProvider.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentHistoryRepository.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepository.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/Payment.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/Payment.java new file mode 100644 index 0000000..b82e6cb --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/Payment.java @@ -0,0 +1,109 @@ +package com.babjo.deliverycommerce.domain.payment.entity; + +import com.babjo.deliverycommerce.global.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Where; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 원장 엔티티 (p_payment) + * - 결제 생명주기: READY -> COMPLETED / FAILED / CANCELED + * - 상태 변경 시마다 PaymentHistory 에 이력 Insert + */ +@Entity +@Table(name = "p_payment") +@Where(clause = "deleted_at IS NULL") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Payment extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "payment_id", updatable = false, nullable = false) + private UUID paymentId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "order_id", nullable = false) + private UUID orderId; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_status", nullable = false, length = 30) + private PaymentStatus paymentStatus; + + @Enumerated(EnumType.STRING) + @Column(name = "pg_provider", length = 30) + private PgProvider pgProvider; + + @Column(name = "pg_payment_key", length = 200) + private String pgPaymentKey; + + @Enumerated(EnumType.STRING) + @Column(name = "payment_method", nullable = false, length = 30) + private PaymentMethod paymentMethod; + + @Column(name = "amount", nullable = false) + private Integer amount; + + @Column(name = "approved_at") + private LocalDateTime approvedAt; + + @Column(name = "last_history_id") + private UUID lastHistoryId; + + // ─────────────────────────────────────────── + // 생성 + // ─────────────────────────────────────────── + + @Builder + private Payment(Long userId, UUID orderId, Integer amount) { + this.userId = userId; + this.orderId = orderId; + this.amount = amount; + this.paymentStatus = PaymentStatus.READY; + this.paymentMethod = PaymentMethod.CARD; + this.pgProvider = PgProvider.TOSS; + } + + public static Payment create(Long userId, UUID orderId, Integer amount) { + return Payment.builder() + .userId(userId) + .orderId(orderId) + .amount(amount) + .build(); + } + + // ─────────────────────────────────────────── + // 상태 전이 메서드 + // ─────────────────────────────────────────── + + /** 결제 승인 (READY → COMPLETED) */ + public void confirm(String pgPaymentKey) { + this.pgPaymentKey = pgPaymentKey; + this.paymentStatus = PaymentStatus.COMPLETED; + this.approvedAt = LocalDateTime.now(); + } + + /** 결제 실패 (READY → FAILED) */ + public void fail() { + this.paymentStatus = PaymentStatus.FAILED; + } + + /** 결제 취소 (COMPLETED or READY → CANCELED) */ + public void cancel() { + this.paymentStatus = PaymentStatus.CANCELED; + } + + /** 최신 이력 ID 갱신 */ + public void updateLastHistoryId(UUID historyId) { + this.lastHistoryId = historyId; + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentHistory.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentHistory.java new file mode 100644 index 0000000..f3eac68 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentHistory.java @@ -0,0 +1,75 @@ +package com.babjo.deliverycommerce.domain.payment.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 이력 엔티티 (p_payment_history) + * - Insert-Only: 삭제/수정 없이 이벤트 발생 시마다 저장 + * - 이전 이력을 previous_history_id 로 연결 (연결 리스트 구조) + * - BaseEntity 상속 없이 필요한 감사 필드만 직접 정의 + */ +@Entity +@Table(name = "p_payment_history") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PaymentHistory { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "payment_history_id", updatable = false, nullable = false) + private UUID paymentHistoryId; + + @Column(name = "payment_id", nullable = false) + private UUID paymentId; + + @Enumerated(EnumType.STRING) + @Column(name = "event_type", nullable = false, length = 30) + private PaymentStatus eventType; + + @Column(name = "amount", nullable = false) + private Integer amount; + + @Column(name = "pg_payment_key", length = 200) + private String pgPaymentKey; + + @Column(name = "reason", length = 255) + private String reason; + + @Column(name = "created_at", nullable = false) + private LocalDateTime createdAt; + + @Column(name = "previous_history_id") + private UUID previousHistoryId; + + @Builder + private PaymentHistory(UUID paymentId, PaymentStatus eventType, Integer amount, + String pgPaymentKey, String reason, UUID previousHistoryId) { + this.paymentId = paymentId; + this.eventType = eventType; + this.amount = amount; + this.pgPaymentKey = pgPaymentKey; + this.reason = reason; + this.previousHistoryId = previousHistoryId; + this.createdAt = LocalDateTime.now(); + } + + public static PaymentHistory create(UUID paymentId, PaymentStatus eventType, Integer amount, + String pgPaymentKey, String reason, UUID previousHistoryId) { + return PaymentHistory.builder() + .paymentId(paymentId) + .eventType(eventType) + .amount(amount) + .pgPaymentKey(pgPaymentKey) + .reason(reason) + .previousHistoryId(previousHistoryId) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentMethod.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentMethod.java new file mode 100644 index 0000000..f112d77 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentMethod.java @@ -0,0 +1,6 @@ +package com.babjo.deliverycommerce.domain.payment.entity; + +public enum PaymentMethod { + CARD +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentStatus.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentStatus.java new file mode 100644 index 0000000..b105e8b --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PaymentStatus.java @@ -0,0 +1,9 @@ +package com.babjo.deliverycommerce.domain.payment.entity; + +public enum PaymentStatus { + READY, + COMPLETED, + FAILED, + CANCELED +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PgProvider.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PgProvider.java new file mode 100644 index 0000000..9ed2fa8 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/entity/PgProvider.java @@ -0,0 +1,6 @@ +package com.babjo.deliverycommerce.domain.payment.entity; + +public enum PgProvider { + TOSS +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java new file mode 100644 index 0000000..e6709f2 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java @@ -0,0 +1,140 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.controller; + +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.service.PaymentService; +import com.babjo.deliverycommerce.global.common.dto.CommonResponse; +import com.babjo.deliverycommerce.global.security.CurrentUserResolver; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +/** + * 결제 컨트롤러 + * - 팀룰: CurrentUserResolver 로 userId 추출 + * - 팀룰: @PreAuthorize 권한 체크 + * - 팀룰: @Valid DTO 검증 + * - 팀룰: CommonResponse 응답 형식 + */ +@Tag(name = "Payment", description = "결제 API") +@RestController +@RequestMapping("/v1/payments") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + private final CurrentUserResolver currentUserResolver; + + // ───────────────────────────────────────────────────────────────── + // POST /v1/payments → 결제 생성 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 생성", description = "주문에 대한 카드 결제 요청을 생성합니다. (상태: READY)") + @PostMapping + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity> createPayment( + Authentication authentication, + @Valid @RequestBody PaymentCreateRequest request + ) { + Long userId = currentUserResolver.getUserId(authentication); + return CommonResponse.created("결제 생성 성공", paymentService.createPayment(request, userId)); + } + + // ───────────────────────────────────────────────────────────────── + // POST /v1/payments/{paymentId}/confirm → 결제 승인 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 승인", description = "PG 승인 완료 처리 (상태: COMPLETED)") + @PostMapping("/{paymentId}/confirm") + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity> confirmPayment( + Authentication authentication, + @PathVariable UUID paymentId, + @Valid @RequestBody PaymentConfirmRequest request + ) { + Long userId = currentUserResolver.getUserId(authentication); + return CommonResponse.ok("결제 승인 성공", paymentService.confirmPayment(paymentId, request, userId)); + } + + // ───────────────────────────────────────────────────────────────── + // PATCH /v1/payments/{paymentId}/fail → 결제 실패 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 실패", description = "결제 실패 상태로 변경합니다. (상태: FAILED)") + @PatchMapping("/{paymentId}/fail") + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity> failPayment( + Authentication authentication, + @PathVariable UUID paymentId + ) { + Long userId = currentUserResolver.getUserId(authentication); + return CommonResponse.ok("결제 실패 처리 성공", paymentService.failPayment(paymentId, userId)); + } + + // ───────────────────────────────────────────────────────────────── + // PATCH /v1/payments/{paymentId}/cancel → 결제 취소 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 취소", description = "결제 취소 처리 (생성 후 5분 이내, 상태: CANCELED)") + @PatchMapping("/{paymentId}/cancel") + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity> cancelPayment( + Authentication authentication, + @PathVariable UUID paymentId, + @RequestBody(required = false) @Valid PaymentCancelRequest request + ) { + Long userId = currentUserResolver.getUserId(authentication); + boolean isAdmin = isAdminRole(authentication); + return CommonResponse.ok("결제 취소 성공", paymentService.cancelPayment(paymentId, request, userId, isAdmin)); + } + + // ───────────────────────────────────────────────────────────────── + // GET /v1/payments → 결제 조회 (단건/목록 통합) + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 조회", description = "paymentId(단건), orderId, paymentStatus 조건 기반 조회") + @GetMapping + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity>> searchPayments( + Authentication authentication, + @RequestParam(required = false) UUID paymentId, + @RequestParam(required = false) UUID orderId, + @RequestParam(required = false) PaymentStatus paymentStatus + ) { + Long userId = currentUserResolver.getUserId(authentication); + boolean isAdmin = isAdminRole(authentication); + return CommonResponse.ok("결제 조회 성공", + paymentService.searchPayments(paymentId, orderId, paymentStatus, userId, isAdmin)); + } + + // ───────────────────────────────────────────────────────────────── + // DELETE /v1/payments/{paymentId} → 결제 기록 Soft Delete + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "결제 삭제", description = "결제 기록 Soft Delete (deleted_at 세팅)") + @DeleteMapping("/{paymentId}") + @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") + public ResponseEntity> deletePayment( + Authentication authentication, + @PathVariable UUID paymentId + ) { + Long userId = currentUserResolver.getUserId(authentication); + boolean isAdmin = isAdminRole(authentication); + return CommonResponse.ok("결제 삭제 성공", paymentService.deletePayment(paymentId, userId, isAdmin)); + } + + // ───────────────────────────────────────────────────────────────── + // 헬퍼: 관리자 역할 확인 + // ───────────────────────────────────────────────────────────────── + private boolean isAdminRole(Authentication authentication) { + return authentication.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_MANAGER") + || a.getAuthority().equals("ROLE_MASTER")); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java new file mode 100644 index 0000000..a843091 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java @@ -0,0 +1,19 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; + +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 결제 취소 요청 DTO + * PATCH /v1/payments/{paymentId}/cancel + * - reason 은 선택사항 + */ +@Getter +@NoArgsConstructor +public class PaymentCancelRequest { + + @Size(max = 255, message = "취소 사유는 255자 이하여야 합니다.") + private String reason; +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java new file mode 100644 index 0000000..3e40777 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java @@ -0,0 +1,20 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 결제 승인 요청 DTO + * POST /v1/payments/{paymentId}/confirm + */ +@Getter +@NoArgsConstructor +public class PaymentConfirmRequest { + + @NotBlank(message = "PG 결제 키는 필수입니다.") + @Size(max = 200, message = "PG 결제 키는 200자 이하여야 합니다.") + private String pgPaymentKey; +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java new file mode 100644 index 0000000..3c914bb --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java @@ -0,0 +1,25 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +/** + * 결제 생성 요청 DTO + * POST /v1/payments + */ +@Getter +@NoArgsConstructor +public class PaymentCreateRequest { + + @NotNull(message = "주문 ID는 필수입니다.") + private UUID orderId; + + @NotNull(message = "결제 금액은 필수입니다.") + @Min(value = 1, message = "결제 금액은 1원 이상이어야 합니다.") + private Integer amount; +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java new file mode 100644 index 0000000..f840f80 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java @@ -0,0 +1,30 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 취소 응답 DTO (200 OK) + */ +@Getter +@Builder +public class PaymentCancelResponse { + + private UUID paymentId; + private PaymentStatus status; + private LocalDateTime canceledAt; + + public static PaymentCancelResponse from(Payment payment) { + return PaymentCancelResponse.builder() + .paymentId(payment.getPaymentId()) + .status(payment.getPaymentStatus()) + .canceledAt(payment.getUpdatedAt()) // 취소 시각은 updatedAt 활용 + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java new file mode 100644 index 0000000..1b02219 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java @@ -0,0 +1,30 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 승인 응답 DTO (200 OK) + */ +@Getter +@Builder +public class PaymentConfirmResponse { + + private UUID paymentId; + private PaymentStatus status; + private LocalDateTime approvedAt; + + public static PaymentConfirmResponse from(Payment payment) { + return PaymentConfirmResponse.builder() + .paymentId(payment.getPaymentId()) + .status(payment.getPaymentStatus()) + .approvedAt(payment.getApprovedAt()) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java new file mode 100644 index 0000000..df1180d --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java @@ -0,0 +1,35 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 생성 응답 DTO (201 Created) + */ +@Getter +@Builder +public class PaymentCreateResponse { + + private UUID paymentId; + private UUID orderId; + private PaymentMethod paymentMethod; + private PaymentStatus status; + private LocalDateTime createdAt; + + public static PaymentCreateResponse from(Payment payment) { + return PaymentCreateResponse.builder() + .paymentId(payment.getPaymentId()) + .orderId(payment.getOrderId()) + .paymentMethod(payment.getPaymentMethod()) + .status(payment.getPaymentStatus()) + .createdAt(payment.getCreatedAt()) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java new file mode 100644 index 0000000..3526821 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java @@ -0,0 +1,25 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import lombok.Builder; +import lombok.Getter; + +import java.util.UUID; + +/** + * 결제 삭제(Soft Delete) 응답 DTO (200 OK) + */ +@Getter +@Builder +public class PaymentDeleteResponse { + + private UUID paymentId; + private boolean deleted; + + public static PaymentDeleteResponse of(UUID paymentId) { + return PaymentDeleteResponse.builder() + .paymentId(paymentId) + .deleted(true) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java new file mode 100644 index 0000000..8c6716c --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java @@ -0,0 +1,30 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 실패 응답 DTO (200 OK) + */ +@Getter +@Builder +public class PaymentFailResponse { + + private UUID paymentId; + private PaymentStatus status; + private LocalDateTime updatedAt; + + public static PaymentFailResponse from(Payment payment) { + return PaymentFailResponse.builder() + .paymentId(payment.getPaymentId()) + .status(payment.getPaymentStatus()) + .updatedAt(payment.getUpdatedAt()) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java new file mode 100644 index 0000000..a6e313d --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java @@ -0,0 +1,52 @@ +package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.entity.PgProvider; +import lombok.Builder; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.util.UUID; + +/** + * 결제 조회 응답 DTO (단건/목록 통합) + */ +@Getter +@Builder +public class PaymentResponse { + + private UUID paymentId; + private Long userId; + private UUID orderId; + private Integer amount; + private PaymentMethod paymentMethod; + private PaymentStatus paymentStatus; + private PgProvider pgProvider; + private String pgPaymentKey; + private LocalDateTime approvedAt; + private LocalDateTime createdAt; + private Long createdBy; + private LocalDateTime updatedAt; + private Long updatedBy; + + public static PaymentResponse from(Payment payment) { + return PaymentResponse.builder() + .paymentId(payment.getPaymentId()) + .userId(payment.getUserId()) + .orderId(payment.getOrderId()) + .amount(payment.getAmount()) + .paymentMethod(payment.getPaymentMethod()) + .paymentStatus(payment.getPaymentStatus()) + .pgProvider(payment.getPgProvider()) + .pgPaymentKey(payment.getPgPaymentKey()) + .approvedAt(payment.getApprovedAt()) + .createdAt(payment.getCreatedAt()) + .createdBy(payment.getCreatedBy()) + .updatedAt(payment.getUpdatedAt()) + .updatedBy(payment.getUpdatedBy()) + .build(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentHistoryRepository.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentHistoryRepository.java new file mode 100644 index 0000000..84a619a --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentHistoryRepository.java @@ -0,0 +1,13 @@ +package com.babjo.deliverycommerce.domain.payment.repository; + +import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface PaymentHistoryRepository extends JpaRepository { + + List findAllByPaymentIdOrderByCreatedAtDesc(UUID paymentId); +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepository.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepository.java new file mode 100644 index 0000000..f9847e8 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepository.java @@ -0,0 +1,28 @@ +package com.babjo.deliverycommerce.domain.payment.repository; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface PaymentRepository extends JpaRepository, PaymentRepositoryCustom { + + // 주문 ID 기준 결제 존재 여부 확인 (이미 결제된 주문 중복 방지) + boolean existsByOrderId(UUID orderId); + + // 주문 ID로 결제 단건 조회 (@Where 적용 → deleted_at IS NULL 자동 필터) + Optional findByOrderId(UUID orderId); + + // userId 기준 목록 조회 + List findAllByUserId(Long userId); + + // ── 관리자용: 삭제된 데이터 포함 조회 ───────────────────────────────── + @Query(value = "SELECT * FROM p_payment WHERE payment_id = :id", nativeQuery = true) + Optional findByIdForAdmin(@Param("id") UUID id); +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java new file mode 100644 index 0000000..ee444be --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java @@ -0,0 +1,20 @@ +package com.babjo.deliverycommerce.domain.payment.repository; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; + +import java.util.List; +import java.util.UUID; + +public interface PaymentRepositoryCustom { + + /** + * 조건 기반 동적 목록 조회 (QueryDSL) + * - paymentId: 단건 조회 용 + * - orderId: 주문별 조회 + * - paymentStatus: 상태별 필터 + * - userId: 본인 결제만 조회 (null이면 전체 - 관리자용) + */ + List searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, Long userId); +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java new file mode 100644 index 0000000..5c15a49 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java @@ -0,0 +1,47 @@ +package com.babjo.deliverycommerce.domain.payment.repository; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.entity.QPayment; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.UUID; + +@Repository +@RequiredArgsConstructor +public class PaymentRepositoryImpl implements PaymentRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Override + public List searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, Long userId) { + QPayment payment = QPayment.payment; + BooleanBuilder builder = new BooleanBuilder(); + + // deleted_at IS NULL 은 @Where 로 자동 적용됨 + if (paymentId != null) { + builder.and(payment.paymentId.eq(paymentId)); + } + if (orderId != null) { + builder.and(payment.orderId.eq(orderId)); + } + if (paymentStatus != null) { + builder.and(payment.paymentStatus.eq(paymentStatus)); + } + // 관리자가 아닌 경우 본인 결제만 조회 + if (userId != null) { + builder.and(payment.userId.eq(userId)); + } + + return queryFactory + .selectFrom(payment) + .where(builder) + .orderBy(payment.createdAt.desc()) + .fetch(); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..5efbeb6 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java @@ -0,0 +1,228 @@ +package com.babjo.deliverycommerce.domain.payment.service; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.repository.PaymentHistoryRepository; +import com.babjo.deliverycommerce.domain.payment.repository.PaymentRepository; +import com.babjo.deliverycommerce.global.exception.CustomException; +import com.babjo.deliverycommerce.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * 결제 서비스 + * - 팀룰: 클래스 레벨 @Transactional(readOnly = true), CUD 메서드에 @Transactional 개별 적용 + * - 상태 변경 시마다 PaymentHistory Insert + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final PaymentHistoryRepository paymentHistoryRepository; + + // ───────────────────────────────────────────────────────────────── + // CREATE + // ───────────────────────────────────────────────────────────────── + + /** + * 결제 생성 (READY 상태) + * - 같은 orderId 로 이미 결제가 존재하면 예외 + */ + @Transactional + public PaymentCreateResponse createPayment(PaymentCreateRequest request, Long userId) { + + // 이미 결제된 주문 중복 방지 + if (paymentRepository.existsByOrderId(request.getOrderId())) { + throw new CustomException(ErrorCode.PAYMENT_ALREADY_EXISTS); + } + + Payment payment = Payment.create(userId, request.getOrderId(), request.getAmount()); + Payment saved = paymentRepository.save(payment); + + // READY 이력 저장 + PaymentHistory history = saveHistory(saved, PaymentStatus.READY, null, null, null); + saved.updateLastHistoryId(history.getPaymentHistoryId()); + + return PaymentCreateResponse.from(saved); + } + + // ───────────────────────────────────────────────────────────────── + // CONFIRM (승인) + // ───────────────────────────────────────────────────────────────── + + /** + * 결제 승인: READY → COMPLETED + */ + @Transactional + public PaymentConfirmResponse confirmPayment(UUID paymentId, PaymentConfirmRequest request, Long userId) { + Payment payment = getPaymentOrThrow(paymentId); + + if (payment.getPaymentStatus() != PaymentStatus.READY) { + throw new CustomException(ErrorCode.PAYMENT_INVALID_STATUS); + } + + payment.confirm(request.getPgPaymentKey()); + + PaymentHistory history = saveHistory(payment, PaymentStatus.COMPLETED, + request.getPgPaymentKey(), null, payment.getLastHistoryId()); + payment.updateLastHistoryId(history.getPaymentHistoryId()); + + return PaymentConfirmResponse.from(payment); + } + + // ───────────────────────────────────────────────────────────────── + // FAIL (실패) + // ───────────────────────────────────────────────────────────────── + + /** + * 결제 실패: READY → FAILED + */ + @Transactional + public PaymentFailResponse failPayment(UUID paymentId, Long userId) { + Payment payment = getPaymentOrThrow(paymentId); + + if (payment.getPaymentStatus() != PaymentStatus.READY) { + throw new CustomException(ErrorCode.PAYMENT_INVALID_STATUS); + } + + payment.fail(); + + PaymentHistory history = saveHistory(payment, PaymentStatus.FAILED, + null, "결제 실패", payment.getLastHistoryId()); + payment.updateLastHistoryId(history.getPaymentHistoryId()); + + return PaymentFailResponse.from(payment); + } + + // ───────────────────────────────────────────────────────────────── + // CANCEL (취소) + // ───────────────────────────────────────────────────────────────── + + /** + * 결제 취소: READY 또는 COMPLETED → CANCELED + * - 결제 생성 5분 이내에만 취소 가능 (요구사항: 주문 취소 5분 이내 제한을 결제에도 적용) + * - CUSTOMER: 본인 결제만 취소 가능 + * - MANAGER/MASTER: 모든 결제 취소 가능 + */ + @Transactional + public PaymentCancelResponse cancelPayment(UUID paymentId, PaymentCancelRequest request, + Long userId, boolean isAdmin) { + Payment payment = getPaymentOrThrow(paymentId); + + // 본인 결제 확인 (관리자는 제외) + if (!isAdmin && !payment.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.PAYMENT_FORBIDDEN); + } + + // 취소 가능 상태 체크 + if (payment.getPaymentStatus() == PaymentStatus.CANCELED) { + throw new CustomException(ErrorCode.PAYMENT_ALREADY_CANCELED); + } + if (payment.getPaymentStatus() == PaymentStatus.FAILED) { + throw new CustomException(ErrorCode.PAYMENT_INVALID_STATUS); + } + + // 5분 이내 취소 가능 체크 + if (payment.getCreatedAt() != null && + payment.getCreatedAt().plusMinutes(5).isBefore(java.time.LocalDateTime.now())) { + throw new CustomException(ErrorCode.PAYMENT_CANCEL_TIME_EXPIRED); + } + + payment.cancel(); + + String reason = (request != null && request.getReason() != null) + ? request.getReason() : "사용자 취소"; + + PaymentHistory history = saveHistory(payment, PaymentStatus.CANCELED, + payment.getPgPaymentKey(), reason, payment.getLastHistoryId()); + payment.updateLastHistoryId(history.getPaymentHistoryId()); + + return PaymentCancelResponse.from(payment); + } + + // ───────────────────────────────────────────────────────────────── + // READ / SEARCH + // ───────────────────────────────────────────────────────────────── + + /** + * 조건 기반 동적 결제 목록 조회 (QueryDSL) + * - CUSTOMER/OWNER: 본인 결제만 조회 (userId 필터 적용) + * - MANAGER/MASTER: 전체 조회 (userId 필터 없음) + */ + public List searchPayments(UUID paymentId, UUID orderId, + PaymentStatus paymentStatus, + Long userId, boolean isAdmin) { + // 관리자면 userId 필터 없이 전체 조회, 그 외는 본인 결제만 + Long filterUserId = isAdmin ? null : userId; + + return paymentRepository.searchPayments(paymentId, orderId, paymentStatus, filterUserId) + .stream() + .map(PaymentResponse::from) + .collect(Collectors.toList()); + } + + // ───────────────────────────────────────────────────────────────── + // DELETE (Soft Delete) + // ───────────────────────────────────────────────────────────────── + + /** + * 결제 기록 Soft Delete + * - CUSTOMER/OWNER: 본인 결제만 삭제 가능 + * - MANAGER/MASTER: 모든 결제 삭제 가능 + */ + @Transactional + public PaymentDeleteResponse deletePayment(UUID paymentId, Long userId, boolean isAdmin) { + Payment payment = getPaymentOrThrow(paymentId); + + if (!isAdmin && !payment.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.PAYMENT_FORBIDDEN); + } + + if (payment.isDeleted()) { + throw new CustomException(ErrorCode.ALREADY_DELETED); + } + + payment.delete(userId); + + return PaymentDeleteResponse.of(paymentId); + } + + // ───────────────────────────────────────────────────────────────── + // 내부 헬퍼 + // ───────────────────────────────────────────────────────────────── + + private Payment getPaymentOrThrow(UUID paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CustomException(ErrorCode.PAYMENT_NOT_FOUND)); + } + + /** + * 결제 이력 저장 (Insert-Only) + */ + private PaymentHistory saveHistory(Payment payment, PaymentStatus eventType, + String pgPaymentKey, String reason, + UUID previousHistoryId) { + PaymentHistory history = PaymentHistory.create( + payment.getPaymentId(), + eventType, + payment.getAmount(), + pgPaymentKey, + reason, + previousHistoryId + ); + return paymentHistoryRepository.save(history); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java index d12b85f..6acc94e 100644 --- a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java +++ b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java @@ -70,7 +70,15 @@ public enum ErrorCode { // ── Review ──────────────────────────────────────── REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_NOT_FOUND", "존재하지 않는 리뷰입니다."), REVIEW_ALREADY_EXISTS(HttpStatus.CONFLICT, "REVIEW_ALREADY_EXISTS", "이미 리뷰를 작성했습니다."), - REVIEW_FORBIDDEN(HttpStatus.FORBIDDEN, "REVIEW_FORBIDDEN", "해당 리뷰에 대한 권한이 없습니다."); + REVIEW_FORBIDDEN(HttpStatus.FORBIDDEN, "REVIEW_FORBIDDEN", "해당 리뷰에 대한 권한이 없습니다."), + + // ── Payment ─────────────────────────────────────── + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_NOT_FOUND", "존재하지 않는 결제입니다."), + PAYMENT_ALREADY_EXISTS(HttpStatus.CONFLICT, "PAYMENT_ALREADY_EXISTS", "해당 주문에 이미 결제가 존재합니다."), + PAYMENT_INVALID_STATUS(HttpStatus.BAD_REQUEST, "PAYMENT_INVALID_STATUS", "현재 결제 상태에서는 해당 작업을 수행할 수 없습니다."), + PAYMENT_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "PAYMENT_ALREADY_CANCELED", "이미 취소된 결제입니다."), + PAYMENT_CANCEL_TIME_EXPIRED(HttpStatus.BAD_REQUEST, "PAYMENT_CANCEL_TIME_EXPIRED", "결제 취소 가능 시간(5분)이 초과되었습니다."), + PAYMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "PAYMENT_FORBIDDEN", "해당 결제에 대한 권한이 없습니다."); private final HttpStatus status; private final String code; From 9eb0312213572c0ea5e8e8df0d4cab8d4ad3db0c Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Fri, 6 Mar 2026 15:44:15 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20PaymentController=20=EB=B0=8F=20P?= =?UTF-8?q?aymentService=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80=20#30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentControllerTest.java | 250 +++++++++++++ .../payment/service/PaymentServiceTest.java | 346 ++++++++++++++++++ 2 files changed, 596 insertions(+) create mode 100644 src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java create mode 100644 src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java new file mode 100644 index 0000000..070bd6b --- /dev/null +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java @@ -0,0 +1,250 @@ +package com.babjo.deliverycommerce.domain.payment.controller; + +import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.entity.PgProvider; +import com.babjo.deliverycommerce.domain.payment.presentation.controller.PaymentController; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.service.PaymentService; +import com.babjo.deliverycommerce.global.security.CurrentUserResolver; +import com.fasterxml.jackson.databind.ObjectMapper; +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.boot.test.mock.mockito.MockBean; +import org.springframework.http.MediaType; +import org.springframework.security.core.Authentication; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(PaymentController.class) +@DisplayName("PaymentController 단위 테스트") +class PaymentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private PaymentService paymentService; + + @MockBean + private CurrentUserResolver currentUserResolver; + + // ───────────────────────────────────────────────────────────────── + // POST /v1/payments → 결제 생성 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 생성 - 성공 (201 Created)") + void createPayment_success() throws Exception { + // given + UUID orderId = UUID.randomUUID(); + UUID paymentId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.createPayment(any(), eq(1L))) + .willReturn(PaymentCreateResponse.builder() + .paymentId(paymentId) + .orderId(orderId) + .paymentMethod(PaymentMethod.CARD) + .status(PaymentStatus.READY) + .createdAt(LocalDateTime.now()) + .build()); + + String body = objectMapper.writeValueAsString( + new java.util.HashMap() {{ + put("orderId", orderId.toString()); + put("amount", 15000); + }} + ); + + // when & then + mockMvc.perform(post("/v1/payments") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.status").value("READY")) + .andExpect(jsonPath("$.data.paymentMethod").value("CARD")); + } + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 생성 - 실패 (amount 누락, 400 Bad Request)") + void createPayment_fail_missingAmount() throws Exception { + // given + String body = "{\"orderId\":\"550e8400-e29b-41d4-a716-446655440000\"}"; + + // when & then + mockMvc.perform(post("/v1/payments") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()); + } + + // ───────────────────────────────────────────────────────────────── + // POST /v1/payments/{paymentId}/confirm → 결제 승인 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 승인 - 성공 (200 OK)") + void confirmPayment_success() throws Exception { + // given + UUID paymentId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.confirmPayment(eq(paymentId), any(), eq(1L))) + .willReturn(PaymentConfirmResponse.builder() + .paymentId(paymentId) + .status(PaymentStatus.COMPLETED) + .approvedAt(LocalDateTime.now()) + .build()); + + String body = "{\"pgPaymentKey\":\"pg_test_123456789\"}"; + + // when & then + mockMvc.perform(post("/v1/payments/{paymentId}/confirm", paymentId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("COMPLETED")); + } + + // ───────────────────────────────────────────────────────────────── + // PATCH /v1/payments/{paymentId}/fail → 결제 실패 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 실패 처리 - 성공 (200 OK)") + void failPayment_success() throws Exception { + // given + UUID paymentId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.failPayment(eq(paymentId), eq(1L))) + .willReturn(PaymentFailResponse.builder() + .paymentId(paymentId) + .status(PaymentStatus.FAILED) + .updatedAt(LocalDateTime.now()) + .build()); + + // when & then + mockMvc.perform(patch("/v1/payments/{paymentId}/fail", paymentId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("FAILED")); + } + + // ───────────────────────────────────────────────────────────────── + // PATCH /v1/payments/{paymentId}/cancel → 결제 취소 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 취소 - 성공 (200 OK)") + void cancelPayment_success() throws Exception { + // given + UUID paymentId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.cancelPayment(eq(paymentId), any(), eq(1L), eq(false))) + .willReturn(PaymentCancelResponse.builder() + .paymentId(paymentId) + .status(PaymentStatus.CANCELED) + .canceledAt(LocalDateTime.now()) + .build()); + + // when & then + mockMvc.perform(patch("/v1/payments/{paymentId}/cancel", paymentId) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CANCELED")); + } + + // ───────────────────────────────────────────────────────────────── + // GET /v1/payments → 결제 조회 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 목록 조회 - 성공 (200 OK)") + void searchPayments_success() throws Exception { + // given + UUID paymentId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.searchPayments(any(), any(), any(), eq(1L), eq(false))) + .willReturn(List.of( + PaymentResponse.builder() + .paymentId(paymentId) + .userId(1L) + .orderId(orderId) + .amount(15000) + .paymentMethod(PaymentMethod.CARD) + .paymentStatus(PaymentStatus.READY) + .pgProvider(PgProvider.TOSS) + .createdAt(LocalDateTime.now()) + .createdBy(1L) + .build() + )); + + // when & then + mockMvc.perform(get("/v1/payments") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].paymentStatus").value("READY")) + .andExpect(jsonPath("$.data[0].paymentMethod").value("CARD")); + } + + // ───────────────────────────────────────────────────────────────── + // DELETE /v1/payments/{paymentId} → 결제 삭제 + // ───────────────────────────────────────────────────────────────── + + @Test + @WithMockUser(roles = "CUSTOMER") + @DisplayName("결제 삭제 (Soft Delete) - 성공 (200 OK)") + void deletePayment_success() throws Exception { + // given + UUID paymentId = UUID.randomUUID(); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + given(paymentService.deletePayment(eq(paymentId), eq(1L), eq(false))) + .willReturn(PaymentDeleteResponse.of(paymentId)); + + // when & then + mockMvc.perform(delete("/v1/payments/{paymentId}", paymentId) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.deleted").value(true)); + } + + @Test + @DisplayName("결제 조회 - 미인증 요청 (401 Unauthorized)") + void searchPayments_fail_unauthorized() throws Exception { + mockMvc.perform(get("/v1/payments")) + .andExpect(status().isUnauthorized()); + } +} + diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java new file mode 100644 index 0000000..0f488d4 --- /dev/null +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java @@ -0,0 +1,346 @@ +package com.babjo.deliverycommerce.domain.payment.service; + +import com.babjo.deliverycommerce.domain.payment.entity.Payment; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; +import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.repository.PaymentHistoryRepository; +import com.babjo.deliverycommerce.domain.payment.repository.PaymentRepository; +import com.babjo.deliverycommerce.global.exception.CustomException; +import com.babjo.deliverycommerce.global.exception.ErrorCode; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("PaymentService 단위 테스트") +class PaymentServiceTest { + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private PaymentHistoryRepository paymentHistoryRepository; + + @InjectMocks + private PaymentService paymentService; + + private UUID paymentId; + private UUID orderId; + private Long userId; + private Payment payment; + private PaymentHistory mockHistory; + + @BeforeEach + void setUp() { + paymentId = UUID.randomUUID(); + orderId = UUID.randomUUID(); + userId = 1L; + payment = Payment.create(userId, orderId, 15000); + mockHistory = PaymentHistory.create(paymentId, PaymentStatus.READY, 15000, null, null, null); + } + + // ───────────────────────────────────────────────────────────────── + // 결제 생성 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 생성 (createPayment)") + class CreatePayment { + + @Test + @DisplayName("정상 케이스 - READY 상태로 결제 생성 성공") + void createPayment_success() { + // given + PaymentCreateRequest request = createRequest(orderId, 15000); + given(paymentRepository.existsByOrderId(orderId)).willReturn(false); + given(paymentRepository.save(any(Payment.class))).willReturn(payment); + given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); + + // when + PaymentCreateResponse response = paymentService.createPayment(request, userId); + + // then + assertThat(response).isNotNull(); + assertThat(response.getStatus()).isEqualTo(PaymentStatus.READY); + then(paymentRepository).should().save(any(Payment.class)); + then(paymentHistoryRepository).should().save(any(PaymentHistory.class)); + } + + @Test + @DisplayName("실패 케이스 - 이미 결제된 주문이면 예외 발생") + void createPayment_fail_alreadyExists() { + // given + PaymentCreateRequest request = createRequest(orderId, 15000); + given(paymentRepository.existsByOrderId(orderId)).willReturn(true); + + // when & then + assertThatThrownBy(() -> paymentService.createPayment(request, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_ALREADY_EXISTS.getMessage()); + } + + private PaymentCreateRequest createRequest(UUID orderId, int amount) { + try { + PaymentCreateRequest req = new PaymentCreateRequest(); + var orderIdField = PaymentCreateRequest.class.getDeclaredField("orderId"); + var amountField = PaymentCreateRequest.class.getDeclaredField("amount"); + orderIdField.setAccessible(true); + amountField.setAccessible(true); + orderIdField.set(req, orderId); + amountField.set(req, amount); + return req; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + // ───────────────────────────────────────────────────────────────── + // 결제 승인 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 승인 (confirmPayment)") + class ConfirmPayment { + + @Test + @DisplayName("정상 케이스 - READY → COMPLETED 성공") + void confirmPayment_success() { + // given + PaymentConfirmRequest request = createConfirmRequest("pg_key_123"); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); + + // when + PaymentConfirmResponse response = paymentService.confirmPayment(paymentId, request, userId); + + // then + assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED); + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.COMPLETED); + } + + @Test + @DisplayName("실패 케이스 - READY 가 아닌 상태에서 승인 시도시 예외") + void confirmPayment_fail_invalidStatus() { + // given + payment.fail(); // FAILED 상태로 변경 + PaymentConfirmRequest request = createConfirmRequest("pg_key_123"); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_INVALID_STATUS.getMessage()); + } + + @Test + @DisplayName("실패 케이스 - 존재하지 않는 paymentId 로 승인 시도시 예외") + void confirmPayment_fail_notFound() { + // given + PaymentConfirmRequest request = createConfirmRequest("pg_key_123"); + given(paymentRepository.findById(paymentId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_NOT_FOUND.getMessage()); + } + + private PaymentConfirmRequest createConfirmRequest(String key) { + try { + PaymentConfirmRequest req = new PaymentConfirmRequest(); + var field = PaymentConfirmRequest.class.getDeclaredField("pgPaymentKey"); + field.setAccessible(true); + field.set(req, key); + return req; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + } + + // ───────────────────────────────────────────────────────────────── + // 결제 실패 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 실패 (failPayment)") + class FailPayment { + + @Test + @DisplayName("정상 케이스 - READY → FAILED 성공") + void failPayment_success() { + // given + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); + + // when + PaymentFailResponse response = paymentService.failPayment(paymentId, userId); + + // then + assertThat(response.getStatus()).isEqualTo(PaymentStatus.FAILED); + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.FAILED); + } + + @Test + @DisplayName("실패 케이스 - 이미 COMPLETED 상태에서 실패 처리 시 예외") + void failPayment_fail_invalidStatus() { + // given + payment.confirm("pg_key"); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.failPayment(paymentId, userId)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_INVALID_STATUS.getMessage()); + } + } + + // ───────────────────────────────────────────────────────────────── + // 결제 취소 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 취소 (cancelPayment)") + class CancelPayment { + + @Test + @DisplayName("정상 케이스 - READY → CANCELED (5분 이내)") + void cancelPayment_success() { + // given + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); + + // when + PaymentCancelResponse response = paymentService.cancelPayment(paymentId, null, userId, false); + + // then + assertThat(response.getStatus()).isEqualTo(PaymentStatus.CANCELED); + } + + @Test + @DisplayName("실패 케이스 - 이미 CANCELED 상태에서 취소 시 예외") + void cancelPayment_fail_alreadyCanceled() { + // given + payment.cancel(); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.cancelPayment(paymentId, null, userId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_ALREADY_CANCELED.getMessage()); + } + + @Test + @DisplayName("실패 케이스 - 본인 결제가 아닌 경우 취소 시 예외 (비관리자)") + void cancelPayment_fail_forbidden() { + // given + Long otherUserId = 999L; + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.cancelPayment(paymentId, null, otherUserId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_FORBIDDEN.getMessage()); + } + } + + // ───────────────────────────────────────────────────────────────── + // 결제 조회 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 조회 (searchPayments)") + class SearchPayments { + + @Test + @DisplayName("정상 케이스 - 결제 목록 조회 성공 (일반 사용자)") + void searchPayments_success_user() { + // given + given(paymentRepository.searchPayments(null, null, null, userId)) + .willReturn(List.of(payment)); + + // when + List responses = paymentService.searchPayments(null, null, null, userId, false); + + // then + assertThat(responses).hasSize(1); + } + + @Test + @DisplayName("정상 케이스 - 관리자는 전체 조회 (userId 필터 없음)") + void searchPayments_success_admin() { + // given + given(paymentRepository.searchPayments(null, null, null, null)) + .willReturn(List.of(payment)); + + // when + List responses = paymentService.searchPayments(null, null, null, userId, true); + + // then + assertThat(responses).hasSize(1); + // 관리자 조회 시 userId=null 로 호출됨을 검증 + then(paymentRepository).should().searchPayments(null, null, null, null); + } + } + + // ───────────────────────────────────────────────────────────────── + // 결제 삭제 + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("결제 삭제 (deletePayment)") + class DeletePayment { + + @Test + @DisplayName("정상 케이스 - Soft Delete 성공") + void deletePayment_success() { + // given + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when + PaymentDeleteResponse response = paymentService.deletePayment(paymentId, userId, false); + + // then + assertThat(response.isDeleted()).isTrue(); + assertThat(payment.isDeleted()).isTrue(); + } + + @Test + @DisplayName("실패 케이스 - 존재하지 않는 결제 삭제 시 예외") + void deletePayment_fail_notFound() { + // given + given(paymentRepository.findById(paymentId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> paymentService.deletePayment(paymentId, userId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_NOT_FOUND.getMessage()); + } + + @Test + @DisplayName("실패 케이스 - Soft Delete 후 재삭제 시 예외") + void deletePayment_fail_alreadyDeleted() { + // given + payment.delete(userId); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.deletePayment(paymentId, userId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.ALREADY_DELETED.getMessage()); + } + } +} + From 0b35aebcc566dc44bfc221aeddb63b6c89cafa5c Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Fri, 6 Mar 2026 15:52:46 +0900 Subject: [PATCH 03/15] =?UTF-8?q?refactor:=20refactor=20payment=20DTO=20an?= =?UTF-8?q?d=20controller=20package=20=EA=B5=AC=EC=A1=B0=20#30?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentController.java | 10 +++++----- .../dto/request/PaymentCancelRequest.java | 2 +- .../dto/request/PaymentConfirmRequest.java | 2 +- .../dto/request/PaymentCreateRequest.java | 2 +- .../dto/response/PaymentCancelResponse.java | 2 +- .../dto/response/PaymentConfirmResponse.java | 2 +- .../dto/response/PaymentCreateResponse.java | 2 +- .../dto/response/PaymentDeleteResponse.java | 2 +- .../dto/response/PaymentFailResponse.java | 2 +- .../dto/response/PaymentResponse.java | 2 +- .../domain/payment/service/PaymentService.java | 8 ++++---- .../payment/controller/PaymentControllerTest.java | 3 +-- .../domain/payment/service/PaymentServiceTest.java | 7 +++---- 13 files changed, 22 insertions(+), 24 deletions(-) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/controller/PaymentController.java (95%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/request/PaymentCancelRequest.java (83%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/request/PaymentConfirmRequest.java (86%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/request/PaymentCreateRequest.java (87%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentCancelResponse.java (90%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentConfirmResponse.java (90%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentCreateResponse.java (92%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentDeleteResponse.java (85%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentFailResponse.java (90%) rename src/main/java/com/babjo/deliverycommerce/domain/payment/{presentation => }/dto/response/PaymentResponse.java (95%) diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java similarity index 95% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java index e6709f2..00dfa3b 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/controller/PaymentController.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java @@ -1,10 +1,10 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.controller; +package com.babjo.deliverycommerce.domain.payment.controller; +import com.babjo.deliverycommerce.domain.payment.dto.response.*; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCancelRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCreateRequest; import com.babjo.deliverycommerce.domain.payment.service.PaymentService; import com.babjo.deliverycommerce.global.common.dto.CommonResponse; import com.babjo.deliverycommerce.global.security.CurrentUserResolver; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCancelRequest.java similarity index 83% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCancelRequest.java index a843091..94b2390 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCancelRequest.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCancelRequest.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; +package com.babjo.deliverycommerce.domain.payment.dto.request; import jakarta.validation.constraints.Size; import lombok.Getter; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentConfirmRequest.java similarity index 86% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentConfirmRequest.java index 3e40777..cf3e523 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentConfirmRequest.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentConfirmRequest.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; +package com.babjo.deliverycommerce.domain.payment.dto.request; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCreateRequest.java similarity index 87% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCreateRequest.java index 3c914bb..3c5c181 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/request/PaymentCreateRequest.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentCreateRequest.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.request; +package com.babjo.deliverycommerce.domain.payment.dto.request; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCancelResponse.java similarity index 90% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCancelResponse.java index f840f80..d132ae2 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCancelResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCancelResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentConfirmResponse.java similarity index 90% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentConfirmResponse.java index 1b02219..eec0090 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentConfirmResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentConfirmResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCreateResponse.java similarity index 92% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCreateResponse.java index df1180d..2178076 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentCreateResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentCreateResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentDeleteResponse.java similarity index 85% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentDeleteResponse.java index 3526821..14f7b92 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentDeleteResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentDeleteResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentFailResponse.java similarity index 90% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentFailResponse.java index 8c6716c..3bb2064 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentFailResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentFailResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentResponse.java similarity index 95% rename from src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java rename to src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentResponse.java index a6e313d..30f7137 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/presentation/dto/response/PaymentResponse.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/response/PaymentResponse.java @@ -1,4 +1,4 @@ -package com.babjo.deliverycommerce.domain.payment.presentation.dto.response; +package com.babjo.deliverycommerce.domain.payment.dto.response; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java index 5efbeb6..6313501 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java @@ -1,12 +1,12 @@ package com.babjo.deliverycommerce.domain.payment.service; +import com.babjo.deliverycommerce.domain.payment.dto.response.*; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCancelRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCreateRequest; import com.babjo.deliverycommerce.domain.payment.repository.PaymentHistoryRepository; import com.babjo.deliverycommerce.domain.payment.repository.PaymentRepository; import com.babjo.deliverycommerce.global.exception.CustomException; diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java index 070bd6b..ce03dd9 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java @@ -1,10 +1,9 @@ package com.babjo.deliverycommerce.domain.payment.controller; +import com.babjo.deliverycommerce.domain.payment.dto.response.*; import com.babjo.deliverycommerce.domain.payment.entity.PaymentMethod; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; import com.babjo.deliverycommerce.domain.payment.entity.PgProvider; -import com.babjo.deliverycommerce.domain.payment.presentation.controller.PaymentController; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; import com.babjo.deliverycommerce.domain.payment.service.PaymentService; import com.babjo.deliverycommerce.global.security.CurrentUserResolver; import com.fasterxml.jackson.databind.ObjectMapper; diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java index 0f488d4..2b7493b 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java @@ -1,12 +1,11 @@ package com.babjo.deliverycommerce.domain.payment.service; +import com.babjo.deliverycommerce.domain.payment.dto.response.*; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCancelRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentConfirmRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.request.PaymentCreateRequest; -import com.babjo.deliverycommerce.domain.payment.presentation.dto.response.*; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentConfirmRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCreateRequest; import com.babjo.deliverycommerce.domain.payment.repository.PaymentHistoryRepository; import com.babjo.deliverycommerce.domain.payment.repository.PaymentRepository; import com.babjo.deliverycommerce.global.exception.CustomException; From c6eb50fb09118c6163fb580030a6c3648d5c7201 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Mon, 9 Mar 2026 15:58:04 +0900 Subject: [PATCH 04/15] feat: implement pagination and search functionality for payments #30 --- .../payment/controller/PaymentController.java | 40 +++++++----- .../dto/request/PaymentSearchRequest.java | 64 +++++++++++++++++++ .../repository/PaymentRepositoryCustom.java | 9 ++- .../repository/PaymentRepositoryImpl.java | 44 ++++++++++++- .../payment/service/PaymentService.java | 52 ++++++++++----- .../controller/PaymentControllerTest.java | 47 ++++++++------ .../payment/service/PaymentServiceTest.java | 40 +++++++----- 7 files changed, 223 insertions(+), 73 deletions(-) create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentSearchRequest.java diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java index 00dfa3b..42ae879 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentController.java @@ -1,7 +1,7 @@ package com.babjo.deliverycommerce.domain.payment.controller; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentSearchRequest; import com.babjo.deliverycommerce.domain.payment.dto.response.*; -import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCancelRequest; import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentConfirmRequest; import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCreateRequest; @@ -12,12 +12,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; -import java.util.List; import java.util.UUID; /** @@ -53,7 +53,7 @@ public ResponseEntity> createPayment( // ───────────────────────────────────────────────────────────────── // POST /v1/payments/{paymentId}/confirm → 결제 승인 // ───────────────────────────────────────────────────────────────── - @Operation(summary = "결제 승인", description = "PG 승인 완료 처리 (상태: COMPLETED)") + @Operation(summary = "결제 승인", description = "PG 승인 완료 처리 (상태: COMPLETED). 본인 결제 또는 관리자만 가능.") @PostMapping("/{paymentId}/confirm") @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") public ResponseEntity> confirmPayment( @@ -62,13 +62,15 @@ public ResponseEntity> confirmPayment( @Valid @RequestBody PaymentConfirmRequest request ) { Long userId = currentUserResolver.getUserId(authentication); - return CommonResponse.ok("결제 승인 성공", paymentService.confirmPayment(paymentId, request, userId)); + boolean isAdmin = isAdminRole(authentication); + return CommonResponse.ok("결제 승인 성공", + paymentService.confirmPayment(paymentId, request, userId, isAdmin)); } // ───────────────────────────────────────────────────────────────── // PATCH /v1/payments/{paymentId}/fail → 결제 실패 // ───────────────────────────────────────────────────────────────── - @Operation(summary = "결제 실패", description = "결제 실패 상태로 변경합니다. (상태: FAILED)") + @Operation(summary = "결제 실패", description = "결제 실패 상태로 변경합니다. (상태: FAILED). 본인 결제 또는 관리자만 가능.") @PatchMapping("/{paymentId}/fail") @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") public ResponseEntity> failPayment( @@ -76,13 +78,15 @@ public ResponseEntity> failPayment( @PathVariable UUID paymentId ) { Long userId = currentUserResolver.getUserId(authentication); - return CommonResponse.ok("결제 실패 처리 성공", paymentService.failPayment(paymentId, userId)); + boolean isAdmin = isAdminRole(authentication); + return CommonResponse.ok("결제 실패 처리 성공", + paymentService.failPayment(paymentId, userId, isAdmin)); } // ───────────────────────────────────────────────────────────────── // PATCH /v1/payments/{paymentId}/cancel → 결제 취소 // ───────────────────────────────────────────────────────────────── - @Operation(summary = "결제 취소", description = "결제 취소 처리 (생성 후 5분 이내, 상태: CANCELED)") + @Operation(summary = "결제 취소", description = "결제 취소 처리 (생성 후 5분 이내, 상태: CANCELED). 본인 결제 또는 관리자만 가능.") @PatchMapping("/{paymentId}/cancel") @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") public ResponseEntity> cancelPayment( @@ -92,31 +96,32 @@ public ResponseEntity> cancelPayment( ) { Long userId = currentUserResolver.getUserId(authentication); boolean isAdmin = isAdminRole(authentication); - return CommonResponse.ok("결제 취소 성공", paymentService.cancelPayment(paymentId, request, userId, isAdmin)); + return CommonResponse.ok("결제 취소 성공", + paymentService.cancelPayment(paymentId, request, userId, isAdmin)); } // ───────────────────────────────────────────────────────────────── - // GET /v1/payments → 결제 조회 (단건/목록 통합) + // GET /v1/payments → 결제 조회 (단건/목록 통합, 페이지네이션) // ───────────────────────────────────────────────────────────────── - @Operation(summary = "결제 조회", description = "paymentId(단건), orderId, paymentStatus 조건 기반 조회") + @Operation(summary = "결제 조회", + description = "paymentId(단건), orderId, paymentStatus 조건 기반 조회. " + + "size: 10/30/50 (기본 10), sortBy: createdAt/amount, sortDir: asc/desc") @GetMapping @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") - public ResponseEntity>> searchPayments( + public ResponseEntity>> searchPayments( Authentication authentication, - @RequestParam(required = false) UUID paymentId, - @RequestParam(required = false) UUID orderId, - @RequestParam(required = false) PaymentStatus paymentStatus + @Valid PaymentSearchRequest searchRequest ) { Long userId = currentUserResolver.getUserId(authentication); boolean isAdmin = isAdminRole(authentication); return CommonResponse.ok("결제 조회 성공", - paymentService.searchPayments(paymentId, orderId, paymentStatus, userId, isAdmin)); + paymentService.searchPayments(searchRequest, userId, isAdmin)); } // ───────────────────────────────────────────────────────────────── // DELETE /v1/payments/{paymentId} → 결제 기록 Soft Delete // ───────────────────────────────────────────────────────────────── - @Operation(summary = "결제 삭제", description = "결제 기록 Soft Delete (deleted_at 세팅)") + @Operation(summary = "결제 삭제", description = "결제 기록 Soft Delete (deleted_at 세팅). 본인 결제 또는 관리자만 가능.") @DeleteMapping("/{paymentId}") @PreAuthorize("hasAnyRole('CUSTOMER', 'OWNER', 'MANAGER', 'MASTER')") public ResponseEntity> deletePayment( @@ -125,7 +130,8 @@ public ResponseEntity> deletePayment( ) { Long userId = currentUserResolver.getUserId(authentication); boolean isAdmin = isAdminRole(authentication); - return CommonResponse.ok("결제 삭제 성공", paymentService.deletePayment(paymentId, userId, isAdmin)); + return CommonResponse.ok("결제 삭제 성공", + paymentService.deletePayment(paymentId, userId, isAdmin)); } // ───────────────────────────────────────────────────────────────── diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentSearchRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentSearchRequest.java new file mode 100644 index 0000000..2073c88 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/dto/request/PaymentSearchRequest.java @@ -0,0 +1,64 @@ +package com.babjo.deliverycommerce.domain.payment.dto.request; + +import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.Set; +import java.util.UUID; + +/** + * 결제 검색 요청 DTO + * GET /v1/payments + * - size: 10, 30, 50만 허용 (기본값 10) + * - page: 0-based (기본값 0) + * - sortBy: 정렬 기준 (createdAt | amount) - 기본값: createdAt + * - sortDir: 정렬 방향 (desc | asc) - 기본값: desc + */ +@Getter +@Setter +@NoArgsConstructor +public class PaymentSearchRequest { + + private static final Set ALLOWED_SORT_FIELDS = Set.of("createdAt", "amount"); + + private UUID paymentId; + private UUID orderId; + private PaymentStatus paymentStatus; + + @Min(0) + private int page = 0; + + private int size = 10; + + private String sortBy = "createdAt"; + + private String sortDir = "desc"; + + @AssertTrue(message = "페이지 크기는 10, 30, 50만 가능합니다.") + public boolean isValidSize() { + return size == 10 || size == 30 || size == 50; + } + + @AssertTrue(message = "정렬 기준은 createdAt, amount 중 하나여야 합니다.") + public boolean isValidSortBy() { + return sortBy != null && ALLOWED_SORT_FIELDS.contains(sortBy); + } + + @AssertTrue(message = "정렬 방향은 asc 또는 desc 여야 합니다.") + public boolean isValidSortDir() { + return "asc".equalsIgnoreCase(sortDir) || "desc".equalsIgnoreCase(sortDir); + } + + public Pageable toPageable() { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) + ? Sort.Direction.ASC : Sort.Direction.DESC; + return PageRequest.of(page, size, Sort.by(direction, sortBy)); + } +} diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java index ee444be..e236f08 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryCustom.java @@ -2,19 +2,22 @@ import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; -import java.util.List; import java.util.UUID; public interface PaymentRepositoryCustom { /** - * 조건 기반 동적 목록 조회 (QueryDSL) + * 조건 기반 동적 목록 조회 (QueryDSL + Pagination) * - paymentId: 단건 조회 용 * - orderId: 주문별 조회 * - paymentStatus: 상태별 필터 * - userId: 본인 결제만 조회 (null이면 전체 - 관리자용) + * - pageable: 페이지네이션 & 정렬 (size: 10/30/50, 기본 createdAt DESC) */ - List searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, Long userId); + Page searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, + Long userId, Pageable pageable); } diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java index 5c15a49..a745aac 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/repository/PaymentRepositoryImpl.java @@ -4,11 +4,18 @@ import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; import com.babjo.deliverycommerce.domain.payment.entity.QPayment; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; import java.util.UUID; @Repository @@ -18,7 +25,8 @@ public class PaymentRepositoryImpl implements PaymentRepositoryCustom { private final JPAQueryFactory queryFactory; @Override - public List searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, Long userId) { + public Page searchPayments(UUID paymentId, UUID orderId, PaymentStatus paymentStatus, + Long userId, Pageable pageable) { QPayment payment = QPayment.payment; BooleanBuilder builder = new BooleanBuilder(); @@ -37,11 +45,41 @@ public List searchPayments(UUID paymentId, UUID orderId, PaymentStatus builder.and(payment.userId.eq(userId)); } - return queryFactory + // Pageable 기반 동적 정렬 적용 + OrderSpecifier[] orderSpecifiers = pageable.getSort().stream() + .map(order -> toOrderSpecifier(payment, order)) + .toArray(OrderSpecifier[]::new); + + List content = queryFactory .selectFrom(payment) .where(builder) - .orderBy(payment.createdAt.desc()) + .orderBy(orderSpecifiers.length > 0 ? orderSpecifiers + : new OrderSpecifier[]{payment.createdAt.desc()}) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) .fetch(); + + long total = Optional.ofNullable( + queryFactory + .select(payment.count()) + .from(payment) + .where(builder) + .fetchOne() + ).orElse(0L); + + return new PageImpl<>(content, pageable, total); + } + + /** + * Pageable Sort.Order → QueryDSL OrderSpecifier 변환 + * 지원 정렬 기준: createdAt, amount + */ + private OrderSpecifier toOrderSpecifier(QPayment payment, Sort.Order order) { + Order direction = order.isAscending() ? Order.ASC : Order.DESC; + if ("amount".equals(order.getProperty())) { + return new OrderSpecifier<>(direction, payment.amount); + } + return new OrderSpecifier<>(direction, payment.createdAt); } } diff --git a/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java index 6313501..49b70aa 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/payment/service/PaymentService.java @@ -1,5 +1,6 @@ package com.babjo.deliverycommerce.domain.payment.service; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentSearchRequest; import com.babjo.deliverycommerce.domain.payment.dto.response.*; import com.babjo.deliverycommerce.domain.payment.entity.Payment; import com.babjo.deliverycommerce.domain.payment.entity.PaymentHistory; @@ -12,12 +13,12 @@ import com.babjo.deliverycommerce.global.exception.CustomException; import com.babjo.deliverycommerce.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; +import java.time.LocalDateTime; import java.util.UUID; -import java.util.stream.Collectors; /** * 결제 서비스 @@ -64,11 +65,18 @@ public PaymentCreateResponse createPayment(PaymentCreateRequest request, Long us /** * 결제 승인: READY → COMPLETED + * - CUSTOMER/OWNER: 본인 결제만 승인 가능 + * - MANAGER/MASTER: 모든 결제 승인 가능 */ @Transactional - public PaymentConfirmResponse confirmPayment(UUID paymentId, PaymentConfirmRequest request, Long userId) { + public PaymentConfirmResponse confirmPayment(UUID paymentId, PaymentConfirmRequest request, + Long userId, boolean isAdmin) { Payment payment = getPaymentOrThrow(paymentId); + if (!isAdmin && !payment.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.PAYMENT_FORBIDDEN); + } + if (payment.getPaymentStatus() != PaymentStatus.READY) { throw new CustomException(ErrorCode.PAYMENT_INVALID_STATUS); } @@ -88,11 +96,17 @@ public PaymentConfirmResponse confirmPayment(UUID paymentId, PaymentConfirmReque /** * 결제 실패: READY → FAILED + * - CUSTOMER/OWNER: 본인 결제만 실패 처리 가능 + * - MANAGER/MASTER: 모든 결제 실패 처리 가능 */ @Transactional - public PaymentFailResponse failPayment(UUID paymentId, Long userId) { + public PaymentFailResponse failPayment(UUID paymentId, Long userId, boolean isAdmin) { Payment payment = getPaymentOrThrow(paymentId); + if (!isAdmin && !payment.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.PAYMENT_FORBIDDEN); + } + if (payment.getPaymentStatus() != PaymentStatus.READY) { throw new CustomException(ErrorCode.PAYMENT_INVALID_STATUS); } @@ -112,8 +126,8 @@ public PaymentFailResponse failPayment(UUID paymentId, Long userId) { /** * 결제 취소: READY 또는 COMPLETED → CANCELED - * - 결제 생성 5분 이내에만 취소 가능 (요구사항: 주문 취소 5분 이내 제한을 결제에도 적용) - * - CUSTOMER: 본인 결제만 취소 가능 + * - 결제 생성 5분 이내에만 취소 가능 (요구사항) + * - CUSTOMER/OWNER: 본인 결제만 취소 가능 * - MANAGER/MASTER: 모든 결제 취소 가능 */ @Transactional @@ -135,8 +149,10 @@ public PaymentCancelResponse cancelPayment(UUID paymentId, PaymentCancelRequest } // 5분 이내 취소 가능 체크 - if (payment.getCreatedAt() != null && - payment.getCreatedAt().plusMinutes(5).isBefore(java.time.LocalDateTime.now())) { + // createdAt은 JPA persist 이후 자동 세팅되므로 항상 존재해야 함 + // null 방어 대신 null이면 시간 제한 우회되는 허점을 차단 + LocalDateTime createdAt = payment.getCreatedAt(); + if (createdAt == null || createdAt.plusMinutes(5).isBefore(LocalDateTime.now())) { throw new CustomException(ErrorCode.PAYMENT_CANCEL_TIME_EXPIRED); } @@ -157,20 +173,24 @@ public PaymentCancelResponse cancelPayment(UUID paymentId, PaymentCancelRequest // ───────────────────────────────────────────────────────────────── /** - * 조건 기반 동적 결제 목록 조회 (QueryDSL) + * 조건 기반 동적 결제 목록 조회 (QueryDSL + Pagination) * - CUSTOMER/OWNER: 본인 결제만 조회 (userId 필터 적용) * - MANAGER/MASTER: 전체 조회 (userId 필터 없음) + * - 페이지 사이즈: 10/30/50 (기본값 10, PaymentSearchRequest 검증) + * - 정렬: createdAt(기본) / amount, asc / desc */ - public List searchPayments(UUID paymentId, UUID orderId, - PaymentStatus paymentStatus, + public Page searchPayments(PaymentSearchRequest searchRequest, Long userId, boolean isAdmin) { // 관리자면 userId 필터 없이 전체 조회, 그 외는 본인 결제만 Long filterUserId = isAdmin ? null : userId; - return paymentRepository.searchPayments(paymentId, orderId, paymentStatus, filterUserId) - .stream() - .map(PaymentResponse::from) - .collect(Collectors.toList()); + return paymentRepository.searchPayments( + searchRequest.getPaymentId(), + searchRequest.getOrderId(), + searchRequest.getPaymentStatus(), + filterUserId, + searchRequest.toPageable()) + .map(PaymentResponse::from); } // ───────────────────────────────────────────────────────────────── @@ -190,6 +210,8 @@ public PaymentDeleteResponse deletePayment(UUID paymentId, Long userId, boolean throw new CustomException(ErrorCode.PAYMENT_FORBIDDEN); } + // @Where(deleted_at IS NULL)에 의해 findById 자체에서 이미 삭제된 데이터는 조회 안 됨 + // 그러나 명시적 예외 처리로 일관성 유지 if (payment.isDeleted()) { throw new CustomException(ErrorCode.ALREADY_DELETED); } diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java index ce03dd9..578ff48 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/controller/PaymentControllerTest.java @@ -11,7 +11,9 @@ 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.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.security.core.Authentication; import org.springframework.security.test.context.support.WithMockUser; @@ -37,10 +39,10 @@ class PaymentControllerTest { @Autowired private ObjectMapper objectMapper; - @MockBean + @MockitoBean private PaymentService paymentService; - @MockBean + @MockitoBean private CurrentUserResolver currentUserResolver; // ───────────────────────────────────────────────────────────────── @@ -109,7 +111,7 @@ void confirmPayment_success() throws Exception { UUID paymentId = UUID.randomUUID(); given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); - given(paymentService.confirmPayment(eq(paymentId), any(), eq(1L))) + given(paymentService.confirmPayment(eq(paymentId), any(), eq(1L), eq(false))) .willReturn(PaymentConfirmResponse.builder() .paymentId(paymentId) .status(PaymentStatus.COMPLETED) @@ -139,7 +141,7 @@ void failPayment_success() throws Exception { UUID paymentId = UUID.randomUUID(); given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); - given(paymentService.failPayment(eq(paymentId), eq(1L))) + given(paymentService.failPayment(eq(paymentId), eq(1L), eq(false))) .willReturn(PaymentFailResponse.builder() .paymentId(paymentId) .status(PaymentStatus.FAILED) @@ -194,27 +196,32 @@ void searchPayments_success() throws Exception { UUID orderId = UUID.randomUUID(); given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); - given(paymentService.searchPayments(any(), any(), any(), eq(1L), eq(false))) - .willReturn(List.of( - PaymentResponse.builder() - .paymentId(paymentId) - .userId(1L) - .orderId(orderId) - .amount(15000) - .paymentMethod(PaymentMethod.CARD) - .paymentStatus(PaymentStatus.READY) - .pgProvider(PgProvider.TOSS) - .createdAt(LocalDateTime.now()) - .createdBy(1L) - .build() + given(paymentService.searchPayments(any(), eq(1L), eq(false))) + .willReturn(new PageImpl<>( + List.of( + PaymentResponse.builder() + .paymentId(paymentId) + .userId(1L) + .orderId(orderId) + .amount(15000) + .paymentMethod(PaymentMethod.CARD) + .paymentStatus(PaymentStatus.READY) + .pgProvider(PgProvider.TOSS) + .createdAt(LocalDateTime.now()) + .createdBy(1L) + .build() + ), + PageRequest.of(0, 10), + 1 )); // when & then mockMvc.perform(get("/v1/payments") .with(csrf())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data[0].paymentStatus").value("READY")) - .andExpect(jsonPath("$.data[0].paymentMethod").value("CARD")); + .andExpect(jsonPath("$.data.content[0].paymentStatus").value("READY")) + .andExpect(jsonPath("$.data.content[0].paymentMethod").value("CARD")) + .andExpect(jsonPath("$.data.totalElements").value(1)); } // ───────────────────────────────────────────────────────────────── diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java index 2b7493b..34b5fe3 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java @@ -6,6 +6,7 @@ import com.babjo.deliverycommerce.domain.payment.entity.PaymentStatus; import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentConfirmRequest; import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentCreateRequest; +import com.babjo.deliverycommerce.domain.payment.dto.request.PaymentSearchRequest; import com.babjo.deliverycommerce.domain.payment.repository.PaymentHistoryRepository; import com.babjo.deliverycommerce.domain.payment.repository.PaymentRepository; import com.babjo.deliverycommerce.global.exception.CustomException; @@ -18,6 +19,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import java.util.List; import java.util.Optional; @@ -25,6 +29,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.BDDMockito.*; @ExtendWith(MockitoExtension.class) @@ -126,7 +131,7 @@ void confirmPayment_success() { given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); // when - PaymentConfirmResponse response = paymentService.confirmPayment(paymentId, request, userId); + PaymentConfirmResponse response = paymentService.confirmPayment(paymentId, request, userId, false); // then assertThat(response.getStatus()).isEqualTo(PaymentStatus.COMPLETED); @@ -142,7 +147,7 @@ void confirmPayment_fail_invalidStatus() { given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); // when & then - assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId)) + assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId, false)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.PAYMENT_INVALID_STATUS.getMessage()); } @@ -155,7 +160,7 @@ void confirmPayment_fail_notFound() { given(paymentRepository.findById(paymentId)).willReturn(Optional.empty()); // when & then - assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId)) + assertThatThrownBy(() -> paymentService.confirmPayment(paymentId, request, userId, false)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.PAYMENT_NOT_FOUND.getMessage()); } @@ -188,7 +193,7 @@ void failPayment_success() { given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); // when - PaymentFailResponse response = paymentService.failPayment(paymentId, userId); + PaymentFailResponse response = paymentService.failPayment(paymentId, userId, false); // then assertThat(response.getStatus()).isEqualTo(PaymentStatus.FAILED); @@ -203,7 +208,7 @@ void failPayment_fail_invalidStatus() { given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); // when & then - assertThatThrownBy(() -> paymentService.failPayment(paymentId, userId)) + assertThatThrownBy(() -> paymentService.failPayment(paymentId, userId, false)) .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.PAYMENT_INVALID_STATUS.getMessage()); } @@ -265,33 +270,38 @@ void cancelPayment_fail_forbidden() { class SearchPayments { @Test - @DisplayName("정상 케이스 - 결제 목록 조회 성공 (일반 사용자)") + @DisplayName("정상 케이스 - 결제 목록 조회 성공 (일반 사용자, 페이지네이션)") void searchPayments_success_user() { // given - given(paymentRepository.searchPayments(null, null, null, userId)) - .willReturn(List.of(payment)); + PaymentSearchRequest request = new PaymentSearchRequest(); + Page pageResult = new PageImpl<>(List.of(payment), PageRequest.of(0, 10), 1); + given(paymentRepository.searchPayments(isNull(), isNull(), isNull(), any(), any())) + .willReturn(pageResult); // when - List responses = paymentService.searchPayments(null, null, null, userId, false); + Page responses = paymentService.searchPayments(request, userId, false); // then - assertThat(responses).hasSize(1); + assertThat(responses.getContent()).hasSize(1); + assertThat(responses.getTotalElements()).isEqualTo(1); } @Test @DisplayName("정상 케이스 - 관리자는 전체 조회 (userId 필터 없음)") void searchPayments_success_admin() { // given - given(paymentRepository.searchPayments(null, null, null, null)) - .willReturn(List.of(payment)); + PaymentSearchRequest request = new PaymentSearchRequest(); + Page pageResult = new PageImpl<>(List.of(payment), PageRequest.of(0, 10), 1); + given(paymentRepository.searchPayments(isNull(), isNull(), isNull(), isNull(), any())) + .willReturn(pageResult); // when - List responses = paymentService.searchPayments(null, null, null, userId, true); + Page responses = paymentService.searchPayments(request, userId, true); // then - assertThat(responses).hasSize(1); + assertThat(responses.getContent()).hasSize(1); // 관리자 조회 시 userId=null 로 호출됨을 검증 - then(paymentRepository).should().searchPayments(null, null, null, null); + then(paymentRepository).should().searchPayments(isNull(), isNull(), isNull(), isNull(), any()); } } From 44adf45313f22f91a80dca38d138ce0b35a94188 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 10:39:39 +0900 Subject: [PATCH 05/15] feat: enhance review management with pagination and role-based access control --- .../review/controller/ReviewController.java | 82 ++++++-- .../review/dto/ReviewSearchRequest.java | 66 +++++++ .../review/repository/ReviewRepository.java | 13 ++ .../domain/review/service/ReviewService.java | 178 ++++++++++-------- 4 files changed, 241 insertions(+), 98 deletions(-) create mode 100644 src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewSearchRequest.java diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/controller/ReviewController.java b/src/main/java/com/babjo/deliverycommerce/domain/review/controller/ReviewController.java index 16b4241..b7f2edc 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/controller/ReviewController.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/controller/ReviewController.java @@ -1,63 +1,113 @@ package com.babjo.deliverycommerce.domain.review.controller; -import java.util.List; -import java.util.UUID; - import com.babjo.deliverycommerce.domain.review.dto.*; import com.babjo.deliverycommerce.domain.review.service.ReviewService; import com.babjo.deliverycommerce.global.common.dto.CommonResponse; +import com.babjo.deliverycommerce.global.security.CurrentUserResolver; import com.babjo.deliverycommerce.global.security.UserPrincipal; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; +import java.util.UUID; + +/** + * 리뷰 컨트롤러 + * - 팀룰: CurrentUserResolver 로 userId 추출 + * - 팀룰: UserPrincipal.getRole() 로 role 추출 후 서비스에 전달 + * - 팀룰: @PreAuthorize 권한 체크 + * - 팀룰: @Valid DTO 검증 + * - 팀룰: CommonResponse 응답 형식 + * - 팀룰: @Tag / @Operation Swagger 문서화 + */ +@Tag(name = "Review", description = "리뷰 API") @RestController @RequestMapping("/v1/reviews") @RequiredArgsConstructor public class ReviewController { private final ReviewService reviewService; + private final CurrentUserResolver currentUserResolver; + // ───────────────────────────────────────────────────────────────── + // POST /v1/reviews → 리뷰 생성 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "리뷰 생성", + description = "주문 완료 후 가게에 리뷰를 작성합니다. CUSTOMER만 가능.") @PostMapping @PreAuthorize("hasRole('CUSTOMER')") public ResponseEntity> createReview( - @AuthenticationPrincipal UserPrincipal principal, + Authentication authentication, @Valid @RequestBody ReviewCreateRequest request ) { - return CommonResponse.created("리뷰 생성 성공", reviewService.createReview(principal, request)); + Long userId = currentUserResolver.getUserId(authentication); + return CommonResponse.created("리뷰 생성 성공", reviewService.createReview(userId, request)); } + // ───────────────────────────────────────────────────────────────── + // PUT /v1/reviews/{reviewId} → 리뷰 수정 + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "리뷰 수정", + description = "본인 리뷰 또는 MANAGER·MASTER만 수정 가능. rating·content 선택적 수정.") @PutMapping("/{reviewId}") @PreAuthorize("hasAnyRole('CUSTOMER', 'MANAGER', 'MASTER')") public ResponseEntity> updateReview( - @AuthenticationPrincipal UserPrincipal principal, + Authentication authentication, @PathVariable UUID reviewId, @Valid @RequestBody ReviewUpdateRequest request ) { - return CommonResponse.ok("리뷰 수정 성공", reviewService.updateReview(principal, reviewId, request)); + Long userId = currentUserResolver.getUserId(authentication); + String role = getRole(authentication); + return CommonResponse.ok("리뷰 수정 성공", + reviewService.updateReview(userId, role, reviewId, request)); } + // ───────────────────────────────────────────────────────────────── + // GET /v1/reviews → 리뷰 조회 (단건/목록 통합, 페이지네이션) + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "리뷰 조회", + description = "reviewId(단건), storeId(가게별), 파라미터 없음(CUSTOMER:본인 / MANAGER·MASTER:전체). " + + "size: 10/30/50 (기본 10), sortBy: createdAt/rating, sortDir: asc/desc") @GetMapping @PreAuthorize("hasAnyRole('CUSTOMER', 'MANAGER', 'MASTER')") - public ResponseEntity>> getReviews( - @AuthenticationPrincipal UserPrincipal principal, - @RequestParam(required = false) UUID reviewId, // 우선순위 1: 단건 조회 - @RequestParam(required = false) UUID storeId // 우선순위 2: 가게별 목록 조회 + public ResponseEntity>> getReviews( + Authentication authentication, + @Valid ReviewSearchRequest searchRequest ) { - // 파라미터 우선순위: reviewId > storeId > 전체(MANAGER/MASTER) 또는 본인(CUSTOMER/OWNER) - return CommonResponse.ok("리뷰 조회 성공", reviewService.getReviews(principal, reviewId, storeId)); + Long userId = currentUserResolver.getUserId(authentication); + String role = getRole(authentication); + return CommonResponse.ok("리뷰 조회 성공", + reviewService.getReviews(userId, role, searchRequest)); } + // ───────────────────────────────────────────────────────────────── + // DELETE /v1/reviews/{reviewId} → 리뷰 Soft Delete + // ───────────────────────────────────────────────────────────────── + @Operation(summary = "리뷰 삭제", + description = "본인 리뷰 또는 MANAGER·MASTER만 Soft Delete 가능.") @DeleteMapping("/{reviewId}") @PreAuthorize("hasAnyRole('CUSTOMER', 'MANAGER', 'MASTER')") public ResponseEntity> deleteReview( - @AuthenticationPrincipal UserPrincipal principal, + Authentication authentication, @PathVariable UUID reviewId ) { - reviewService.deleteReview(reviewId, principal); + Long userId = currentUserResolver.getUserId(authentication); + String role = getRole(authentication); + reviewService.deleteReview(reviewId, userId, role); return CommonResponse.ok("리뷰 삭제 성공", null); } + + // ───────────────────────────────────────────────────────────────── + // 헬퍼: 현재 사용자 Role 추출 ("ROLE_MANAGER" 형태) + // ───────────────────────────────────────────────────────────────── + private String getRole(Authentication authentication) { + UserPrincipal principal = (UserPrincipal) authentication.getPrincipal(); + return principal.getRole(); + } } diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewSearchRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewSearchRequest.java new file mode 100644 index 0000000..44ddf73 --- /dev/null +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewSearchRequest.java @@ -0,0 +1,66 @@ +package com.babjo.deliverycommerce.domain.review.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.Min; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import java.util.Set; +import java.util.UUID; + +/** + * 리뷰 검색 요청 DTO + * GET /v1/reviews + * - size: 10, 30, 50만 허용 (기본값 10) + * - page: 0-based (기본값 0) + * - sortBy: createdAt | rating (기본값 createdAt) + * - sortDir: desc | asc (기본값 desc) + */ +@Getter +@Setter +@NoArgsConstructor +public class ReviewSearchRequest { + + private static final Set ALLOWED_SORT_FIELDS = Set.of("createdAt", "rating"); + + /** 단건 조회용 (우선순위 1) */ + private UUID reviewId; + + /** 가게별 목록 조회용 (우선순위 2) */ + private UUID storeId; + + @Min(0) + private int page = 0; + + private int size = 10; + + private String sortBy = "createdAt"; + + private String sortDir = "desc"; + + @AssertTrue(message = "페이지 크기는 10, 30, 50만 가능합니다.") + public boolean isValidSize() { + return size == 10 || size == 30 || size == 50; + } + + @AssertTrue(message = "정렬 기준은 createdAt, rating 중 하나여야 합니다.") + public boolean isValidSortBy() { + return sortBy != null && ALLOWED_SORT_FIELDS.contains(sortBy); + } + + @AssertTrue(message = "정렬 방향은 asc 또는 desc 여야 합니다.") + public boolean isValidSortDir() { + return "asc".equalsIgnoreCase(sortDir) || "desc".equalsIgnoreCase(sortDir); + } + + public Pageable toPageable() { + Sort.Direction direction = "asc".equalsIgnoreCase(sortDir) + ? Sort.Direction.ASC : Sort.Direction.DESC; + return PageRequest.of(page, size, Sort.by(direction, sortBy)); + } +} + diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java b/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java index 93a326d..56f6a0d 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java @@ -1,9 +1,12 @@ package com.babjo.deliverycommerce.domain.review.repository; import com.babjo.deliverycommerce.domain.review.entity.Review; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.lang.NonNull; import org.springframework.stereotype.Repository; import java.util.List; @@ -19,9 +22,19 @@ public interface ReviewRepository extends JpaRepository { // 가게별 리뷰 목록 - @Where 자동 적용으로 삭제된 리뷰 제외 List findAllByStore_StoreId(UUID storeId); + // 가게별 리뷰 페이지 조회 - 페이지네이션 적용 + Page findAllByStore_StoreId(UUID storeId, Pageable pageable); + // 유저별 리뷰 목록 - @Where 자동 적용으로 삭제된 리뷰 제외 List findAllByUser_UserId(Long userId); + // 유저별 리뷰 페이지 조회 - 페이지네이션 적용 + Page findAllByUser_UserId(Long userId, Pageable pageable); + + // 전체 리뷰 페이지 조회 (관리자용) - @Where 자동 적용 + @NonNull + Page findAll(@NonNull Pageable pageable); + // 관리자용 - 삭제된 리뷰 포함 단건 조회 (Native Query로 @Where 우회) @Query(value = "SELECT * FROM p_review WHERE review_id = :id", nativeQuery = true) Optional findByIdForAdmin(@Param("id") UUID id); diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java b/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java index 2d1ffda..33af49b 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java @@ -3,6 +3,7 @@ import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; +import com.babjo.deliverycommerce.domain.review.dto.ReviewSearchRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateResponse; import com.babjo.deliverycommerce.domain.review.entity.Review; @@ -13,63 +14,62 @@ import com.babjo.deliverycommerce.global.common.enums.UserEnumRole; import com.babjo.deliverycommerce.global.exception.CustomException; import com.babjo.deliverycommerce.global.exception.ErrorCode; -import com.babjo.deliverycommerce.global.security.UserPrincipal; import com.babjo.deliverycommerce.domain.user.entity.User; import com.babjo.deliverycommerce.domain.user.repository.UserRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.UUID; -import java.util.stream.Collectors; // [TODO] Order 도메인 연결 후 import 추가 필요 // import com.babjo.deliverycommerce.domain.order.entity.Order; // import com.babjo.deliverycommerce.domain.order.entity.OrderStatus; // import com.babjo.deliverycommerce.domain.order.repository.OrderRepository; +/** + * 리뷰 서비스 + * - 팀룰: 클래스 레벨 @Transactional(readOnly = true), CUD 메서드에 @Transactional 개별 적용 + * - 팀룰: UserPrincipal 직접 의존 없이 userId(Long) + role(String) 파라미터로 처리 + * (Controller에서 CurrentUserResolver / UserPrincipal.getRole() 추출 후 전달) + */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class ReviewService { + private final ReviewRepository reviewRepository; private final StoreRepository storeRepository; private final UserRepository userRepository; // private final OrderRepository orderRepository; private final ReviewMapper reviewMapper; + // ───────────────────────────────────────────────────────────────── + // CREATE + // ───────────────────────────────────────────────────────────────── + @Transactional - public ReviewCreateResponse createReview( - UserPrincipal principal, - ReviewCreateRequest createRequest - ) { - User user = userRepository.findById(principal.getUserId()) + public ReviewCreateResponse createReview(Long userId, ReviewCreateRequest createRequest) { + + User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); // Order order = orderRepository.findById(createRequest.getOrderId()) // .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); - - // 주문 상태 COMPLETED 확인 (실패조건: 완료되지 않은 주문) - // if (order.getStatus() != OrderStatus.COMPLETED) { - // throw new CustomException(ErrorCode.ORDER_NOT_FOUND); - // } - - // 중복 리뷰 방지 - 1주문 1리뷰 (실패조건: 해당 주문에 이미 리뷰 존재) - // [TODO] Order 연결 후 주석 해제 - // boolean alreadyExists = reviewRepository.existsByOrderAndDeletedAtIsNull(order); - // if (alreadyExists) { - // throw new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); - // } +// if (order.getStatus() != OrderStatus.COMPLETED) { +// throw new CustomException(ErrorCode.ORDER_NOT_COMPLETED); +// } +// boolean alreadyExists = reviewRepository.existsByOrder(order); +// if (alreadyExists) { +// throw new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); +// } Store store = storeRepository.findByStoreIdAndDeletedAtIsNull(createRequest.getStoreId()) .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); - Review review = reviewMapper.toEntity( - createRequest, - user, -// order, - store - ); + Review review = reviewMapper.toEntity(createRequest, user, store); Review savedReview = reviewRepository.save(review); store.addReview(savedReview.getRating()); @@ -78,84 +78,73 @@ public ReviewCreateResponse createReview( return reviewMapper.toCreateResponse(savedReview); } - public List getReviews(UserPrincipal principal, UUID reviewId, UUID storeId) { - // reviewId 단건 조회 — 본인 리뷰이거나 MANAGER/MASTER만 접근 가능 - if (reviewId != null) { - Review review = reviewRepository.findByReviewId(reviewId) + // ───────────────────────────────────────────────────────────────── + // READ / SEARCH + // ───────────────────────────────────────────────────────────────── + + /** + * 조건 기반 동적 리뷰 목록 조회 (페이지네이션 + 정렬) + * - reviewId: 단건 조회 (우선순위 1) — 본인 또는 MANAGER/MASTER + * - storeId: 가게별 목록 조회 (우선순위 2) — 전체 공개 + * - 파라미터 없음: CUSTOMER → 본인 리뷰만, MANAGER/MASTER → 전체 + * + * @param userId 로그인 사용자 ID (CurrentUserResolver 추출) + * @param role 로그인 사용자 Role (UserPrincipal.getRole() 추출) — "ROLE_MANAGER" 형태 + * @param search 검색 조건 (reviewId, storeId, page, size, sortBy, sortDir) + */ + public Page getReviews(Long userId, String role, ReviewSearchRequest search) { + + // 단건 조회 — reviewId가 존재하면 1건 Page로 래핑해 반환 + if (search.getReviewId() != null) { + Review review = reviewRepository.findByReviewId(search.getReviewId()) .orElseThrow(() -> new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - boolean isAdminRole = isAdminRole(principal); - if (!isAdminRole && !review.getUser().getUserId().equals(principal.getUserId())) { + if (!isAdminRole(role) && !review.getUser().getUserId().equals(userId)) { throw new CustomException(ErrorCode.REVIEW_FORBIDDEN); } - return List.of(reviewMapper.toResponse(review)); + return new PageImpl<>( + List.of(reviewMapper.toResponse(review)), + search.toPageable(), + 1 + ); } - // storeId 필터 조회 — 해당 가게의 리뷰 목록 (전체 공개) - if (storeId != null) { - return reviewRepository.findAllByStore_StoreId(storeId) - .stream() - .map(reviewMapper::toResponse) - .collect(Collectors.toList()); + // 가게별 목록 조회 — 전체 공개 + if (search.getStoreId() != null) { + return reviewRepository.findAllByStore_StoreId(search.getStoreId(), search.toPageable()) + .map(reviewMapper::toResponse); } // 파라미터 없는 전체 조회 - // CUSTOMER: 본인이 작성한 리뷰만 반환 - // MANAGER / MASTER: 전체 리뷰 반환 - if (isAdminRole(principal)) { - return reviewRepository.findAll() - .stream() - .map(reviewMapper::toResponse) - .collect(Collectors.toList()); + // MANAGER/MASTER: 전체 리뷰 + // CUSTOMER/OWNER: 본인이 작성한 리뷰만 + if (isAdminRole(role)) { + return reviewRepository.findAll(search.toPageable()) + .map(reviewMapper::toResponse); } - return reviewRepository.findAllByUser_UserId(principal.getUserId()) - .stream() - .map(reviewMapper::toResponse) - .collect(Collectors.toList()); + return reviewRepository.findAllByUser_UserId(userId, search.toPageable()) + .map(reviewMapper::toResponse); } - @Transactional - public void deleteReview(UUID reviewId, UserPrincipal principal) { - // @Where(deleted_at IS NULL) 적용으로 이미 삭제된 리뷰는 REVIEW_NOT_FOUND 반환 - Review review = reviewRepository.findByReviewId(reviewId) - .orElseThrow(() -> new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - - // 작성자 또는 MANAGER/MASTER만 삭제 가능 - if (!review.getUser().getUserId().equals(principal.getUserId()) && !isAdminRole(principal)) { - throw new CustomException(ErrorCode.REVIEW_FORBIDDEN); - } - - Store store = review.getStore(); - store.removeReview(review.getRating()); - storeRepository.save(store); - - review.delete(principal.getUserId()); - reviewRepository.save(review); - } + // ───────────────────────────────────────────────────────────────── + // UPDATE + // ───────────────────────────────────────────────────────────────── @Transactional - public ReviewUpdateResponse updateReview( - UserPrincipal principal, - UUID reviewId, - ReviewUpdateRequest updateRequest - ) { - // 실패조건: 존재하지 않는 리뷰 + public ReviewUpdateResponse updateReview(Long userId, String role, UUID reviewId, + ReviewUpdateRequest updateRequest) { Review review = reviewRepository.findByReviewId(reviewId) .orElseThrow(() -> new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - // 작성자 또는 MANAGER/MASTER만 수정 가능 - if (!review.getUser().getUserId().equals(principal.getUserId()) && !isAdminRole(principal)) { + if (!review.getUser().getUserId().equals(userId) && !isAdminRole(role)) { throw new CustomException(ErrorCode.REVIEW_FORBIDDEN); } int oldRating = review.getRating(); - - // 수정 처리 (updatedAt은 @LastModifiedDate 로 자동 갱신) review.updateReview(updateRequest.getRating(), updateRequest.getContent()); - // 별점이 변경된 경우에만 Store 통계 갱신 Integer newRating = updateRequest.getRating(); if (newRating != null && oldRating != newRating) { Store store = review.getStore(); @@ -166,12 +155,37 @@ public ReviewUpdateResponse updateReview( return reviewMapper.toUpdateResponse(review); } + // ───────────────────────────────────────────────────────────────── + // DELETE (Soft Delete) + // ───────────────────────────────────────────────────────────────── + + @Transactional + public void deleteReview(UUID reviewId, Long userId, String role) { + Review review = reviewRepository.findByReviewId(reviewId) + .orElseThrow(() -> new CustomException(ErrorCode.REVIEW_NOT_FOUND)); + + if (!review.getUser().getUserId().equals(userId) && !isAdminRole(role)) { + throw new CustomException(ErrorCode.REVIEW_FORBIDDEN); + } + + Store store = review.getStore(); + store.removeReview(review.getRating()); + storeRepository.save(store); + + review.delete(userId); + reviewRepository.save(review); + } + + // ───────────────────────────────────────────────────────────────── + // 내부 헬퍼 + // ───────────────────────────────────────────────────────────────── + /** * MANAGER 또는 MASTER 권한 여부 확인 - * UserPrincipal의 role은 "ROLE_MANAGER" 형태이므로 Authority 상수와 비교합니다. + * role은 "ROLE_MANAGER" 형태 (UserPrincipal.getRole() 반환값) */ - private boolean isAdminRole(UserPrincipal principal) { - String role = principal.getRole(); - return UserEnumRole.Authority.MANAGER.equals(role) || UserEnumRole.Authority.MASTER.equals(role); + private boolean isAdminRole(String role) { + return UserEnumRole.Authority.MANAGER.equals(role) + || UserEnumRole.Authority.MASTER.equals(role); } } \ No newline at end of file From 37bcf4cd5bfcd7d7d6acdd2d38d43d4d86b35942 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 10:42:43 +0900 Subject: [PATCH 06/15] feat: refactor ReviewServiceTest to remove UserPrincipal and implement role-based access using userId and role --- .../review/service/ReviewServiceTest.java | 510 +++++++----------- 1 file changed, 187 insertions(+), 323 deletions(-) diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java index 498be62..ee0fe82 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java @@ -3,6 +3,7 @@ import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; +import com.babjo.deliverycommerce.domain.review.dto.ReviewSearchRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateResponse; import com.babjo.deliverycommerce.domain.review.entity.Review; @@ -13,7 +14,6 @@ import com.babjo.deliverycommerce.global.common.enums.UserEnumRole; import com.babjo.deliverycommerce.global.exception.CustomException; import com.babjo.deliverycommerce.global.exception.ErrorCode; -import com.babjo.deliverycommerce.global.security.UserPrincipal; import com.babjo.deliverycommerce.domain.user.entity.User; import com.babjo.deliverycommerce.domain.user.repository.UserRepository; import org.junit.jupiter.api.BeforeEach; @@ -22,6 +22,9 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import java.util.List; import java.util.Optional; @@ -52,53 +55,70 @@ class ReviewServiceTest { @InjectMocks private ReviewService reviewService; - private UserPrincipal principal; // userId=1L, CUSTOMER - private UserPrincipal otherPrincipal; // userId=2L, CUSTOMER (다른 작성자) - private UserPrincipal managerPrincipal; // userId=3L, MANAGER - private UserPrincipal masterPrincipal; // userId=4L, MASTER - private UserPrincipal ownerPrincipal; // userId=5L, OWNER (리뷰 작성 불가, 삭제/수정도 본인 리뷰 아님) - private Store store; - private User user; + // 팀룰: 서비스는 UserPrincipal 없이 userId(Long) + role(String) 으로 동작 + private static final Long CUSTOMER_ID = 1L; + private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; + + private static final Long OTHER_ID = 2L; + private static final String OTHER_ROLE = "ROLE_CUSTOMER"; + + private static final Long MANAGER_ID = 3L; + private static final String MANAGER_ROLE = "ROLE_MANAGER"; + + private static final Long MASTER_ID = 4L; + private static final String MASTER_ROLE = "ROLE_MASTER"; + + private static final Long OWNER_ID = 5L; + private static final String OWNER_ROLE = "ROLE_OWNER"; + + private Store store; + private User user; private Review review; - @BeforeEach - void setUp() { - principal = new UserPrincipal(1L, "testuser", "ROLE_CUSTOMER"); - otherPrincipal = new UserPrincipal(2L, "otheruser", "ROLE_CUSTOMER"); - managerPrincipal = new UserPrincipal(3L, "manager", "ROLE_MANAGER"); - masterPrincipal = new UserPrincipal(4L, "master", "ROLE_MASTER"); - ownerPrincipal = new UserPrincipal(5L, "owner", "ROLE_OWNER"); + /** 기본 ReviewSearchRequest (page=0, size=10, sortBy=createdAt, sortDir=desc) */ + private ReviewSearchRequest defaultSearch() { + return new ReviewSearchRequest(); + } - store = Store.create(1L, "한식", "테스트 식당", "서울시 강남구"); - user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); + /** reviewId 조건이 있는 ReviewSearchRequest */ + private ReviewSearchRequest searchByReviewId(UUID reviewId) { + ReviewSearchRequest s = new ReviewSearchRequest(); + s.setReviewId(reviewId); + return s; + } + + /** storeId 조건이 있는 ReviewSearchRequest */ + private ReviewSearchRequest searchByStoreId(UUID storeId) { + ReviewSearchRequest s = new ReviewSearchRequest(); + s.setStoreId(storeId); + return s; + } + @BeforeEach + void setUp() { + store = Store.create(1L, "한식", "테스트 식당", "서울시 강남구"); + user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); review = Review.create(user, store, 4, "맛있어요"); } - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── // createReview - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── @Test void createReview_성공() { - // given ReviewCreateRequest request = new ReviewCreateRequest(); - ReviewCreateResponse expectedResponse = ReviewCreateResponse.builder() - .rating(4) - .content("맛있어요") - .build(); + .rating(4).content("맛있어요").build(); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); given(reviewMapper.toCreateResponse(any())).willReturn(expectedResponse); - // when - ReviewCreateResponse result = reviewService.createReview(principal, request); + ReviewCreateResponse result = reviewService.createReview(CUSTOMER_ID, request); - // then assertThat(result.getRating()).isEqualTo(4); assertThat(result.getContent()).isEqualTo("맛있어요"); verify(reviewRepository, times(1)).save(any()); @@ -106,24 +126,18 @@ void setUp() { @Test void createReview_성공_store_통계_갱신됨() { - // given ReviewCreateRequest request = new ReviewCreateRequest(); - ReviewCreateResponse expectedResponse = ReviewCreateResponse.builder() - .rating(4) - .content("맛있어요") - .build(); + .rating(4).content("맛있어요").build(); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); given(reviewMapper.toCreateResponse(any())).willReturn(expectedResponse); - // when - reviewService.createReview(principal, request); + reviewService.createReview(CUSTOMER_ID, request); - // then — store 통계가 갱신되고 저장되어야 함 assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(4.0); verify(storeRepository, times(1)).save(store); @@ -131,12 +145,10 @@ void setUp() { @Test void createReview_실패_존재하지_않는_유저() { - // given ReviewCreateRequest request = new ReviewCreateRequest(); - given(userRepository.findById(1L)).willReturn(Optional.empty()); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.createReview(principal, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.USER_NOT_FOUND)); @@ -146,14 +158,11 @@ void setUp() { @Test void createReview_실패_존재하지_않는_가게() { - // given ReviewCreateRequest request = new ReviewCreateRequest(); - - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.createReview(principal, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.STORE_NOT_FOUND)); @@ -163,15 +172,11 @@ void setUp() { @Test void createReview_실패_소프트삭제된_가게() { - // given — findByStoreIdAndDeletedAtIsNull가 empty를 반환 = soft-delete된 가게 포함 ReviewCreateRequest request = new ReviewCreateRequest(); - - given(userRepository.findById(1L)).willReturn(Optional.of(user)); - // soft-delete된 가게는 findByStoreIdAndDeletedAtIsNull에서 empty 반환 + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.createReview(principal, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.STORE_NOT_FOUND)); @@ -180,44 +185,34 @@ void setUp() { verify(reviewRepository, never()).save(any()); } - // ─────────────────────────────────────────────── - // getReviews - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── + // getReviews (단건 조회 — reviewId 조건) + // ───────────────────────────────────────────────────────────────── @Test void getReviews_CUSTOMER_본인_리뷰_단건조회_성공() { - // given — review 작성자 userId=1L, principal userId=1L (본인) UUID reviewId = UUID.randomUUID(); ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId) - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - // when - List result = reviewService.getReviews(principal, reviewId, null); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId)); - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getReviewId()).isEqualTo(reviewId); - assertThat(result.get(0).getUserId()).isEqualTo(1L); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getReviewId()).isEqualTo(reviewId); + assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); verify(reviewRepository, times(1)).findByReviewId(reviewId); - verify(reviewRepository, never()).findAllByStore_StoreId(any()); - verify(reviewRepository, never()).findAll(); } @Test void getReviews_CUSTOMER_타인_리뷰_단건조회_실패() { - // given — review 작성자 userId=1L, otherPrincipal userId=2L (타인) UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when & then - assertThatThrownBy(() -> reviewService.getReviews(otherPrincipal, reviewId, null)) + // review 작성자 userId=1L, 요청자 OTHER_ID=2L + assertThatThrownBy(() -> reviewService.getReviews(OTHER_ID, OTHER_ROLE, searchByReviewId(reviewId))) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); @@ -225,212 +220,168 @@ void setUp() { @Test void getReviews_MANAGER_타인_리뷰_단건조회_성공() { - // given — MANAGER는 타인 리뷰도 조회 가능 UUID reviewId = UUID.randomUUID(); ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId) - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - // when - List result = reviewService.getReviews(managerPrincipal, reviewId, null); + Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, searchByReviewId(reviewId)); - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getUserId()).isEqualTo(1L); - } - - @Test - void getReviews_단건조회_실패_존재하지_않는_리뷰() { - // given - UUID reviewId = UUID.randomUUID(); - given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - - // when & then - assertThatThrownBy(() -> reviewService.getReviews(principal, reviewId, null)) - .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); } @Test void getReviews_MASTER_타인_리뷰_단건조회_성공() { - // given — MASTER도 타인 리뷰 조회 가능 UUID reviewId = UUID.randomUUID(); ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId) - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - // when - List result = reviewService.getReviews(masterPrincipal, reviewId, null); + Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, searchByReviewId(reviewId)); + + assertThat(result.getContent()).hasSize(1); + } + + @Test + void getReviews_단건조회_실패_존재하지_않는_리뷰() { + UUID reviewId = UUID.randomUUID(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getUserId()).isEqualTo(1L); + assertThatThrownBy(() -> reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId))) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) + .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } + // ───────────────────────────────────────────────────────────────── + // getReviews (가게별 목록 조회 — storeId 조건) + // ───────────────────────────────────────────────────────────────── + @Test void getReviews_storeId_필터_목록조회_성공_CUSTOMER() { - // given — storeId 조회는 CUSTOMER도 모든 리뷰 조회 가능 (공개 정보) UUID storeId = UUID.randomUUID(); ReviewResponse reviewResponse = ReviewResponse.builder() - .storeId(storeId) - .userId(1L) - .rating(3) - .content("보통이에요") - .build(); + .storeId(storeId).userId(CUSTOMER_ID).rating(3).content("보통이에요").build(); - given(reviewRepository.findAllByStore_StoreId(storeId)).willReturn(List.of(review)); + Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any())).willReturn(reviewPage); given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - // when - List result = reviewService.getReviews(principal, null, storeId); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getStoreId()).isEqualTo(storeId); - verify(reviewRepository, times(1)).findAllByStore_StoreId(storeId); - verify(reviewRepository, never()).findAll(); - verify(reviewRepository, never()).findAllByUser_UserId(any()); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getStoreId()).isEqualTo(storeId); + verify(reviewRepository, times(1)).findAllByStore_StoreId(eq(storeId), any()); } @Test void getReviews_storeId_필터_결과없음_빈목록_반환() { - // given UUID storeId = UUID.randomUUID(); - given(reviewRepository.findAllByStore_StoreId(storeId)).willReturn(List.of()); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any())).willReturn(emptyPage); - // when - List result = reviewService.getReviews(principal, null, storeId); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); - // then - assertThat(result).isEmpty(); + assertThat(result.getContent()).isEmpty(); } + // ───────────────────────────────────────────────────────────────── + // getReviews (파라미터 없음 — 권한별 전체/본인) + // ───────────────────────────────────────────────────────────────── + @Test void getReviews_파라미터없음_CUSTOMER_본인_리뷰만_반환() { - // given — CUSTOMER는 본인 작성 리뷰만 반환 ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); + .userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - given(reviewRepository.findAllByUser_UserId(1L)).willReturn(List.of(review)); + Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any())).willReturn(reviewPage); given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - // when - List result = reviewService.getReviews(principal, null, null); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); - // then - assertThat(result).hasSize(1); - assertThat(result.get(0).getUserId()).isEqualTo(1L); - verify(reviewRepository, times(1)).findAllByUser_UserId(1L); - verify(reviewRepository, never()).findAll(); - verify(reviewRepository, never()).findByReviewId(any()); - verify(reviewRepository, never()).findAllByStore_StoreId(any()); + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); + verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any()); + verify(reviewRepository, never()).findAll(any()); } @Test void getReviews_파라미터없음_CUSTOMER_본인_리뷰_없으면_빈목록() { - // given - given(reviewRepository.findAllByUser_UserId(1L)).willReturn(List.of()); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any())).willReturn(emptyPage); - // when - List result = reviewService.getReviews(principal, null, null); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); - // then - assertThat(result).isEmpty(); - verify(reviewRepository, times(1)).findAllByUser_UserId(1L); - verify(reviewRepository, never()).findAll(); + assertThat(result.getContent()).isEmpty(); + verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any()); + verify(reviewRepository, never()).findAll(any()); } @Test void getReviews_파라미터없음_OWNER_본인_리뷰만_반환() { - // given — OWNER도 CUSTOMER와 동일하게 본인 리뷰만 반환 - given(reviewRepository.findAllByUser_UserId(5L)).willReturn(List.of()); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByUser_UserId(eq(OWNER_ID), any())).willReturn(emptyPage); - // when - List result = reviewService.getReviews(ownerPrincipal, null, null); + Page result = reviewService.getReviews(OWNER_ID, OWNER_ROLE, defaultSearch()); - // then - assertThat(result).isEmpty(); - verify(reviewRepository, times(1)).findAllByUser_UserId(5L); - verify(reviewRepository, never()).findAll(); + assertThat(result.getContent()).isEmpty(); + verify(reviewRepository, times(1)).findAllByUser_UserId(eq(OWNER_ID), any()); + verify(reviewRepository, never()).findAll(any()); } @Test void getReviews_파라미터없음_MANAGER_전체_리뷰_반환() { - // given — MANAGER는 전체 리뷰 조회 가능 ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); + .userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - given(reviewRepository.findAll()).willReturn(List.of(review)); + Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAll(any(org.springframework.data.domain.Pageable.class))).willReturn(reviewPage); given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - // when - List result = reviewService.getReviews(managerPrincipal, null, null); + Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, defaultSearch()); - // then - assertThat(result).hasSize(1); - verify(reviewRepository, times(1)).findAll(); - verify(reviewRepository, never()).findAllByUser_UserId(any()); + assertThat(result.getContent()).hasSize(1); + verify(reviewRepository, times(1)).findAll(any(org.springframework.data.domain.Pageable.class)); + verify(reviewRepository, never()).findAllByUser_UserId(any(), any()); } @Test void getReviews_파라미터없음_MASTER_전체_리뷰_반환() { - // given — MASTER도 전체 리뷰 조회 가능 - given(reviewRepository.findAll()).willReturn(List.of()); + Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAll(any(org.springframework.data.domain.Pageable.class))).willReturn(emptyPage); - // when - List result = reviewService.getReviews(masterPrincipal, null, null); + Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, defaultSearch()); - // then - assertThat(result).isEmpty(); - verify(reviewRepository, times(1)).findAll(); - verify(reviewRepository, never()).findAllByUser_UserId(any()); + assertThat(result.getContent()).isEmpty(); + verify(reviewRepository, times(1)).findAll(any(org.springframework.data.domain.Pageable.class)); + verify(reviewRepository, never()).findAllByUser_UserId(any(), any()); } - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── // updateReview - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── @Test void updateReview_성공() { - // given UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); given(request.getRating()).willReturn(5); given(request.getContent()).willReturn("정말 맛있어요"); ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(5) - .content("정말 맛있어요") - .build(); + .reviewId(reviewId).rating(5).content("정말 맛있어요").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - // when - ReviewUpdateResponse result = reviewService.updateReview(principal, reviewId, request); + ReviewUpdateResponse result = reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - // then assertThat(result.getReviewId()).isEqualTo(reviewId); assertThat(result.getRating()).isEqualTo(5); assertThat(result.getContent()).isEqualTo("정말 맛있어요"); @@ -438,15 +389,12 @@ void setUp() { @Test void updateReview_실패_작성자_불일치() { - // given UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - - // review의 작성자는 userId=1L, otherPrincipal은 userId=2L given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when & then - assertThatThrownBy(() -> reviewService.updateReview(otherPrincipal, reviewId, request)) + // review 작성자 userId=1L, OTHER_ID=2L + assertThatThrownBy(() -> reviewService.updateReview(OTHER_ID, OTHER_ROLE, reviewId, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); @@ -456,64 +404,47 @@ void setUp() { @Test void updateReview_성공_MANAGER는_타인_리뷰_수정_가능() { - // given UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); given(request.getRating()).willReturn(3); given(request.getContent()).willReturn("관리자 수정"); ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(3) - .content("관리자 수정") - .build(); + .reviewId(reviewId).rating(3).content("관리자 수정").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - // when - ReviewUpdateResponse result = reviewService.updateReview(managerPrincipal, reviewId, request); + ReviewUpdateResponse result = reviewService.updateReview(MANAGER_ID, MANAGER_ROLE, reviewId, request); - // then assertThat(result.getRating()).isEqualTo(3); } @Test void updateReview_성공_MASTER는_타인_리뷰_수정_가능() { - // given UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); given(request.getRating()).willReturn(2); given(request.getContent()).willReturn("마스터 수정"); ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(2) - .content("마스터 수정") - .build(); + .reviewId(reviewId).rating(2).content("마스터 수정").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - // when - ReviewUpdateResponse result = reviewService.updateReview(masterPrincipal, reviewId, request); + ReviewUpdateResponse result = reviewService.updateReview(MASTER_ID, MASTER_ROLE, reviewId, request); - // then assertThat(result.getRating()).isEqualTo(2); } @Test void updateReview_실패_OWNER는_타인_리뷰_수정_불가() { - // given — OWNER는 본인 리뷰가 아닌 타인 리뷰 수정 불가 UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when & then - assertThatThrownBy(() -> reviewService.updateReview(ownerPrincipal, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(OWNER_ID, OWNER_ROLE, reviewId, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); @@ -521,27 +452,22 @@ void setUp() { @Test void updateReview_별점_변경_시_store_통계_갱신됨() { - // given UUID reviewId = UUID.randomUUID(); store.addReview(4); // count=1, avg=4.0 ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(2); // 4 → 2로 변경 + given(request.getRating()).willReturn(2); given(request.getContent()).willReturn("별로에요"); ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(2) - .content("별로에요") - .build(); + .reviewId(reviewId).rating(2).content("별로에요").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - // when - reviewService.updateReview(principal, reviewId, request); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - // then — 4 → 2 변경: avg = 4.0 + (2 - 4) / 1 = 2.0 + // 4 → 2 변경: avg = 4.0 + (2 - 4) / 1 = 2.0 assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(2.0); verify(storeRepository, times(1)).save(store); @@ -549,72 +475,57 @@ void setUp() { @Test void updateReview_별점_동일하면_store_저장_생략() { - // given UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(4); // oldRating == newRating + given(request.getRating()).willReturn(4); // oldRating == newRating given(request.getContent()).willReturn("내용만 수정"); ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(4) - .content("내용만 수정") - .build(); + .reviewId(reviewId).rating(4).content("내용만 수정").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - // when - reviewService.updateReview(principal, reviewId, request); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - // then — 별점 변경 없으므로 storeRepository.save 호출되지 않아야 함 verify(storeRepository, never()).save(any()); } @Test void updateReview_실패_존재하지_않는_리뷰() { - // given UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.updateReview(principal, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── // deleteReview - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── @Test void deleteReview_성공() { - // given UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when - reviewService.deleteReview(reviewId, principal); + reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); - // then assertThat(review.getDeletedAt()).isNotNull(); - assertThat(review.getDeletedBy()).isEqualTo(principal.getUserId()); + assertThat(review.getDeletedBy()).isEqualTo(CUSTOMER_ID); verify(reviewRepository, times(1)).save(review); } @Test void deleteReview_실패_작성자_불일치() { - // given UUID reviewId = UUID.randomUUID(); - // review의 작성자는 userId=1L, otherPrincipal은 userId=2L + // review 작성자 userId=1L, OTHER_ID=2L given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when & then - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, otherPrincipal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OTHER_ID, OTHER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); @@ -624,42 +535,34 @@ void setUp() { @Test void deleteReview_성공_MANAGER는_타인_리뷰_삭제_가능() { - // given UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when - reviewService.deleteReview(reviewId, managerPrincipal); + reviewService.deleteReview(reviewId, MANAGER_ID, MANAGER_ROLE); - // then assertThat(review.getDeletedAt()).isNotNull(); - assertThat(review.getDeletedBy()).isEqualTo(3L); + assertThat(review.getDeletedBy()).isEqualTo(MANAGER_ID); } @Test void deleteReview_성공_MASTER는_타인_리뷰_삭제_가능() { - // given UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when - reviewService.deleteReview(reviewId, masterPrincipal); + reviewService.deleteReview(reviewId, MASTER_ID, MASTER_ROLE); - // then assertThat(review.getDeletedAt()).isNotNull(); - assertThat(review.getDeletedBy()).isEqualTo(4L); + assertThat(review.getDeletedBy()).isEqualTo(MASTER_ID); } @Test void deleteReview_실패_OWNER는_타인_리뷰_삭제_불가() { - // given — OWNER는 본인 리뷰가 아닌 타인 리뷰 삭제 불가 UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when & then - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, ownerPrincipal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OWNER_ID, OWNER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); @@ -669,14 +572,10 @@ void setUp() { @Test void deleteReview_이미_소프트삭제된_리뷰는_REVIEW_NOT_FOUND_반환() { - // given - // @Where(deleted_at IS NULL) 적용으로 이미 삭제된 리뷰는 - // findByReviewId가 empty를 반환 → REVIEW_NOT_FOUND 발생이 올바른 동작 UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, principal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); @@ -687,16 +586,12 @@ void setUp() { @Test void deleteReview_성공_store_통계_갱신됨() { - // given UUID reviewId = UUID.randomUUID(); - store.addReview(4); // 미리 1개 등록 (count=1, avg=4.0) - + store.addReview(4); // count=1, avg=4.0 given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // when - reviewService.deleteReview(reviewId, principal); + reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); - // then — 삭제 후 count=0, avg=0.0 assertThat(store.getReviewCount()).isEqualTo(0); assertThat(store.getAverageRating()).isEqualTo(0.0); verify(storeRepository, times(1)).save(store); @@ -704,12 +599,10 @@ void setUp() { @Test void deleteReview_실패_존재하지_않는_리뷰() { - // given UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - // when & then - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, principal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); @@ -717,121 +610,99 @@ void setUp() { verify(reviewRepository, never()).save(any()); } - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── // 트랜잭션 롤백 검증 - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── @Test void createReview_트랜잭션_롤백_reviewRepository_save_예외시_store_통계_미반영() { - // given — reviewRepository.save 시 RuntimeException 발생 → @Transactional 롤백 ReviewCreateRequest request = new ReviewCreateRequest(); - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willThrow(new RuntimeException("DB 저장 오류")); - // when & then - assertThatThrownBy(() -> reviewService.createReview(principal, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) .isInstanceOf(RuntimeException.class) .hasMessage("DB 저장 오류"); - // store.addReview는 reviewRepository.save 이후에 호출되므로 아직 호출 안 됨 - // storeRepository.save 도 호출되지 않아야 함 verify(storeRepository, never()).save(store); } @Test void createReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { - // given — storeRepository.save 시 RuntimeException 발생 ReviewCreateRequest request = new ReviewCreateRequest(); - - given(userRepository.findById(1L)).willReturn(Optional.of(user)); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); - // storeRepository.save 시 예외 발생 doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(store); - // when & then — 예외가 전파되어야 함 (@Transactional이 전체 롤백) - assertThatThrownBy(() -> reviewService.createReview(principal, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) .isInstanceOf(RuntimeException.class) .hasMessage("store 저장 오류"); } @Test void updateReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { - // given — 별점 변경 + storeRepository.save 시 RuntimeException 발생 UUID reviewId = UUID.randomUUID(); - store.addReview(4); // count=1, avg=4.0 + store.addReview(4); ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(2); // 4 → 2 변경 + given(request.getRating()).willReturn(2); given(request.getContent()).willReturn("별로에요"); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(any()); - // when & then — 예외가 전파되어야 함 - assertThatThrownBy(() -> reviewService.updateReview(principal, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request)) .isInstanceOf(RuntimeException.class) .hasMessage("store 저장 오류"); } @Test void deleteReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { - // given — store 통계 갱신 후 storeRepository.save 시 예외 발생 UUID reviewId = UUID.randomUUID(); - store.addReview(4); // count=1, avg=4.0 + store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(store); - // when & then — 예외 전파 → @Transactional 롤백 (review soft-delete 미반영) - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, principal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(RuntimeException.class) .hasMessage("store 저장 오류"); - // reviewRepository.save 는 storeRepository.save 이후이므로 호출되지 않아야 함 verify(reviewRepository, never()).save(any()); } @Test void deleteReview_트랜잭션_롤백_reviewRepository_save_예외시_예외_전파() { - // given — storeRepository.save 성공 후 reviewRepository.save 시 예외 발생 UUID reviewId = UUID.randomUUID(); - store.addReview(4); // count=1, avg=4.0 + store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - // storeRepository.save는 정상 동작 given(storeRepository.save(store)).willReturn(store); - // reviewRepository.save 시 예외 발생 doThrow(new RuntimeException("review 저장 오류")).when(reviewRepository).save(review); - // when & then — 예외 전파 → @Transactional이 전체 롤백 - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, principal)) + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(RuntimeException.class) .hasMessage("review 저장 오류"); } - // ─────────────────────────────────────────────── - // 중복 데이터 검증 (REVIEW_ALREADY_EXISTS ErrorCode 존재 확인) - // [TODO] Order 도메인 연결 후 실제 중복 방지 로직 테스트로 교체 - // ─────────────────────────────────────────────── + // ───────────────────────────────────────────────────────────────── + // ErrorCode 존재 검증 (Order 연동 대기 항목 선검증) + // ───────────────────────────────────────────────────────────────── @Test void REVIEW_ALREADY_EXISTS_에러코드가_정의되어_있음() { - // Order 도메인 연결 전에도 REVIEW_ALREADY_EXISTS ErrorCode가 - // 정의되어 있어 추후 연결 시 바로 사용 가능한지 검증 assertThat(ErrorCode.REVIEW_ALREADY_EXISTS).isNotNull(); assertThat(ErrorCode.REVIEW_ALREADY_EXISTS.name()).isEqualTo("REVIEW_ALREADY_EXISTS"); } @Test void createReview_중복검증_REVIEW_ALREADY_EXISTS_예외_발생_가능() { - // Order 도메인 연결 후 중복 리뷰 시 REVIEW_ALREADY_EXISTS를 던져야 함을 검증 - // 현재는 서비스 레이어 외부에서 직접 예외를 생성하여 동작 확인 CustomException ex = new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.REVIEW_ALREADY_EXISTS); @@ -839,18 +710,11 @@ void setUp() { assertThat(ex.getMessage()).isEqualTo("이미 리뷰를 작성했습니다."); } - // ─────────────────────────────────────────────── - // 예외 상황 — 주문 미완료 (Order 도메인 제외, ErrorCode 레벨 검증) - // ─────────────────────────────────────────────── - @Test void ORDER_NOT_FOUND_에러코드가_정의되어_있음() { - // Order 도메인 연결 전에도 ORDER_NOT_FOUND ErrorCode가 정의되어 있는지 검증 assertThat(ErrorCode.ORDER_NOT_FOUND).isNotNull(); assertThat(ErrorCode.ORDER_NOT_FOUND.name()).isEqualTo("ORDER_NOT_FOUND"); assertThat(ErrorCode.ORDER_NOT_FOUND.getStatus().value()).isEqualTo(404); } - } - From 3b2e49fde8f4b882ebe4c48446834033926f50cc Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 11:01:05 +0900 Subject: [PATCH 07/15] feat: refactor ReviewServiceTest to remove UserPrincipal and implement role-based access using userId and role --- .../controller/ReviewControllerTest.java | 688 +++++------------- .../review/service/ReviewServiceTest.java | 620 +++++----------- 2 files changed, 378 insertions(+), 930 deletions(-) diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java index 5f44d84..c42b4cb 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java @@ -1,690 +1,354 @@ package com.babjo.deliverycommerce.domain.review.controller; - import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; +import com.babjo.deliverycommerce.domain.review.dto.ReviewSearchRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateResponse; import com.babjo.deliverycommerce.domain.review.service.ReviewService; import com.babjo.deliverycommerce.global.exception.CustomException; import com.babjo.deliverycommerce.global.exception.ErrorCode; import com.babjo.deliverycommerce.global.exception.GlobalExceptionHandler; +import com.babjo.deliverycommerce.global.security.CurrentUserResolver; import com.babjo.deliverycommerce.global.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; import org.springframework.http.MediaType; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; - import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.UUID; - import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - +/** + * ReviewController unit test + * Team rule: CurrentUserResolver injected as Mock to extract userId + * Team rule: ReviewService called with Long userId + String role (not UserPrincipal) + * Response type: Page + */ class ReviewControllerTest { - private MockMvc mockMvc; - @Mock private ReviewService reviewService; - + @Mock + private CurrentUserResolver currentUserResolver; @InjectMocks private ReviewController reviewController; - - private UserPrincipal customerPrincipal; - + private static final Long CUSTOMER_ID = 1L; + private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; @BeforeEach void setUp() { MockitoAnnotations.openMocks(this); mockMvc = MockMvcBuilders.standaloneSetup(reviewController) .setControllerAdvice(new GlobalExceptionHandler()) .build(); - - customerPrincipal = new UserPrincipal(1L, "testuser", "ROLE_CUSTOMER"); + UserPrincipal customerPrincipal = new UserPrincipal(CUSTOMER_ID, "testuser", CUSTOMER_ROLE); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( customerPrincipal, null, - List.of(new SimpleGrantedAuthority("ROLE_CUSTOMER")) + List.of(new SimpleGrantedAuthority(CUSTOMER_ROLE)) ); SecurityContextHolder.getContext().setAuthentication(auth); + when(currentUserResolver.getUserId(any(Authentication.class))).thenReturn(CUSTOMER_ID); } - - // ─────────────────────────────────────────────── - // POST /v1/reviews - 리뷰 생성 - // ─────────────────────────────────────────────── - + // POST /v1/reviews @Test - void createReview_성공() throws Exception { + void createReview_success() throws Exception { UUID orderId = UUID.randomUUID(); UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId) - .userId(1L) - .orderId(orderId) - .storeId(storeId) - .rating(4) - .content("맛있어요") - .createdAt(LocalDateTime.now()) - .build(); - - when(reviewService.createReview(any(UserPrincipal.class), any())).thenReturn(response); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 4, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + .reviewId(reviewId).userId(CUSTOMER_ID).orderId(orderId).storeId(storeId) + .rating(4).content("good").createdAt(LocalDateTime.now()).build(); + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", orderId, storeId); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) .andExpect(jsonPath("$.data.userId").value(1)) - .andExpect(jsonPath("$.data.orderId").value(orderId.toString())) - .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) .andExpect(jsonPath("$.data.rating").value(4)) - .andExpect(jsonPath("$.data.content").value("맛있어요")); - - verify(reviewService, times(1)).createReview(any(UserPrincipal.class), any()); + .andExpect(jsonPath("$.data.content").value("good")); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); } - @Test - void createReview_성공_경계값_rating_최솟값_1() throws Exception { - UUID orderId = UUID.randomUUID(); + void createReview_boundary_rating_min_1() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId) - .userId(1L) - .storeId(storeId) - .rating(1) - .content("별로에요") - .createdAt(LocalDateTime.now()) - .build(); - - when(reviewService.createReview(any(UserPrincipal.class), any())).thenReturn(response); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 1, - "content": "별로에요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(1).content("bad").createdAt(LocalDateTime.now()).build(); + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":1,\"content\":\"bad\"}", UUID.randomUUID(), storeId); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.rating").value(1)); } - @Test - void createReview_성공_경계값_rating_최댓값_5() throws Exception { - UUID orderId = UUID.randomUUID(); + void createReview_boundary_rating_max_5() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId) - .userId(1L) - .storeId(storeId) - .rating(5) - .content("최고에요") - .createdAt(LocalDateTime.now()) - .build(); - - when(reviewService.createReview(any(UserPrincipal.class), any())).thenReturn(response); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 5, - "content": "최고에요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(5).content("great").createdAt(LocalDateTime.now()).build(); + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":5,\"content\":\"great\"}", UUID.randomUUID(), storeId); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.rating").value(5)); } - @Test - void createReview_유효성검사_실패_필수필드_누락() throws Exception { - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content("{}")) + void createReview_validation_fail_missing_required_fields() throws Exception { + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content("{}")) .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); } - @Test - void createReview_유효성검사_실패_rating_범위초과_6() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 6, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + void createReview_validation_fail_rating_above_5() throws Exception { + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":6,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); } - @Test - void createReview_유효성검사_실패_rating_범위미만_0() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 0, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + void createReview_validation_fail_rating_below_1() throws Exception { + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":0,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); } - @Test - void createReview_유효성검사_실패_content_blank() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 3, - "content": " " - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + void createReview_validation_fail_content_blank() throws Exception { + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":3,\"content\":\" \"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); } - @Test - void createReview_유효성검사_실패_content_null() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 3 - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + void createReview_validation_fail_content_null() throws Exception { + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":3}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); } - @Test - void createReview_실패_유저없음_예외전파() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - when(reviewService.createReview(any(UserPrincipal.class), any())) + void createReview_fail_user_not_found_propagates_404() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) .thenThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 4, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isNotFound()); + } + @Test + void createReview_fail_store_not_found_propagates_404() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.STORE_NOT_FOUND)); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) .andExpect(status().isNotFound()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + @Test + void createReview_fail_duplicate_review_propagates_409() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS)); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isConflict()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); } - - // ─────────────────────────────────────────────── - // PUT /v1/reviews/{reviewId} - 리뷰 수정 - // ─────────────────────────────────────────────── - @Test - void updateReview_성공() throws Exception { + void createReview_fail_unexpected_error_returns_500() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new RuntimeException("unexpected")); + String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); + mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isInternalServerError()); + } + // PUT /v1/reviews/{reviewId} + @Test + void updateReview_success() throws Exception { UUID reviewId = UUID.randomUUID(); - ReviewUpdateResponse response = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(5) - .content("정말 맛있어요") - .updatedAt(LocalDateTime.now()) - .build(); - - when(reviewService.updateReview(any(UserPrincipal.class), eq(reviewId), any())).thenReturn(response); - - String requestBody = """ - { - "rating": 5, - "content": "정말 맛있어요" - } - """; - + .reviewId(reviewId).rating(5).content("very good").updatedAt(LocalDateTime.now()).build(); + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenReturn(response); mockMvc.perform(put("/v1/reviews/" + reviewId) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + .content("{\"rating\":5,\"content\":\"very good\"}")) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) .andExpect(jsonPath("$.data.rating").value(5)) - .andExpect(jsonPath("$.data.content").value("정말 맛있어요")); - - verify(reviewService, times(1)).updateReview(any(UserPrincipal.class), eq(reviewId), any()); + .andExpect(jsonPath("$.data.content").value("very good")); + verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); } - @Test - void updateReview_성공_경계값_rating_최댓값_5() throws Exception { + void updateReview_fail_not_owner_propagates_403() throws Exception { UUID reviewId = UUID.randomUUID(); - - ReviewUpdateResponse response = ReviewUpdateResponse.builder() - .reviewId(reviewId) - .rating(5) - .content("정말 최고에요") - .updatedAt(LocalDateTime.now()) - .build(); - - when(reviewService.updateReview(any(UserPrincipal.class), eq(reviewId), any())).thenReturn(response); - - String requestBody = """ - { - "rating": 5, - "content": "정말 최고에요" - } - """; - + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); mockMvc.perform(put("/v1/reviews/" + reviewId) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.rating").value(5)) - .andExpect(jsonPath("$.data.content").value("정말 최고에요")); + .content("{\"rating\":5,\"content\":\"good\"}")) + .andExpect(status().isForbidden()); + verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); } - @Test - void updateReview_실패_작성자_불일치_예외전파() throws Exception { + void updateReview_validation_fail_rating_below_1() throws Exception { UUID reviewId = UUID.randomUUID(); - - when(reviewService.updateReview(any(UserPrincipal.class), eq(reviewId), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); - - String requestBody = """ - { - "rating": 5, - "content": "정말 맛있어요" - } - """; - mockMvc.perform(put("/v1/reviews/" + reviewId) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isForbidden()); - - verify(reviewService, times(1)).updateReview(any(UserPrincipal.class), eq(reviewId), any()); + .content("{\"rating\":0,\"content\":\"bad\"}")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).updateReview(any(), any(), any(), any()); } - - @Test - void updateReview_유효성검사_실패_rating_범위미만_0() throws Exception { + void updateReview_validation_fail_rating_above_5() throws Exception { UUID reviewId = UUID.randomUUID(); - - String requestBody = """ - { - "rating": 0, - "content": "별로에요" - } - """; - mockMvc.perform(put("/v1/reviews/" + reviewId) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) + .content("{\"rating\":6,\"content\":\"too much\"}")) .andExpect(status().isBadRequest()); - - verify(reviewService, never()).updateReview(any(), any(), any()); + verify(reviewService, never()).updateReview(any(), any(), any(), any()); } - @Test - void updateReview_유효성검사_실패_rating_범위초과_6() throws Exception { + void updateReview_fail_review_not_found_propagates_404() throws Exception { UUID reviewId = UUID.randomUUID(); - - String requestBody = """ - { - "rating": 6, - "content": "너무 맛있어요" - } - """; - + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); mockMvc.perform(put("/v1/reviews/" + reviewId) .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isBadRequest()); - - verify(reviewService, never()).updateReview(any(), any(), any()); + .content("{\"rating\":4,\"content\":\"good\"}")) + .andExpect(status().isNotFound()); + verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); } - - // ─────────────────────────────────────────────── - // GET /v1/reviews - 리뷰 조회 - // ─────────────────────────────────────────────── - + // GET /v1/reviews @Test - void getReviews_CUSTOMER_파라미터없음_본인리뷰_반환() throws Exception { - // standaloneSetup에서 @AuthenticationPrincipal은 SecurityContext의 principal로 주입됨 + void getReviews_customer_no_params_returns_own_reviews() throws Exception { ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(1L) - .rating(4) - .content("맛있어요") - .build(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(null), eq(null))) - .thenReturn(List.of(reviewResponse)); - + .userId(CUSTOMER_ID).rating(4).content("good").build(); + Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) + .thenReturn(page); mockMvc.perform(get("/v1/reviews")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(1))) - .andExpect(jsonPath("$.data[0].userId").value(1)); - - verify(reviewService, times(1)).getReviews(any(UserPrincipal.class), eq(null), eq(null)); + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].userId").value(CUSTOMER_ID)); + verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); } - @Test - void getReviews_CUSTOMER_파라미터없음_빈목록_반환() throws Exception { - when(reviewService.getReviews(any(UserPrincipal.class), eq(null), eq(null))) - .thenReturn(Collections.emptyList()); - + void getReviews_no_params_empty_list() throws Exception { + Page emptyPage = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) + .thenReturn(emptyPage); mockMvc.perform(get("/v1/reviews")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(0))); - - verify(reviewService, times(1)).getReviews(any(UserPrincipal.class), eq(null), eq(null)); + .andExpect(jsonPath("$.data.content", hasSize(0))); + verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); } - @Test - void getReviews_reviewId_단건조회_성공() throws Exception { + void getReviews_by_reviewId_success() throws Exception { UUID reviewId = UUID.randomUUID(); UUID storeId = UUID.randomUUID(); - ReviewResponse reviewResponse = ReviewResponse.builder() - .reviewId(reviewId) - .userId(1L) - .storeId(storeId) - .rating(5) - .content("맛있어요") - .build(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(reviewId), eq(null))) - .thenReturn(List.of(reviewResponse)); - - mockMvc.perform(get("/v1/reviews") - .param("reviewId", reviewId.toString())) + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId).rating(5).content("good").build(); + Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) + .thenReturn(page); + mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(1))) - .andExpect(jsonPath("$.data[0].reviewId").value(reviewId.toString())) - .andExpect(jsonPath("$.data[0].userId").value(1)) - .andExpect(jsonPath("$.data[0].rating").value(5)); - - verify(reviewService, times(1)).getReviews(any(UserPrincipal.class), eq(reviewId), eq(null)); + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].reviewId").value(reviewId.toString())) + .andExpect(jsonPath("$.data.content[0].rating").value(5)); + verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); } - @Test - void getReviews_reviewId_타인_리뷰_조회_403_예외전파() throws Exception { + void getReviews_by_reviewId_forbidden_propagates_403() throws Exception { UUID reviewId = UUID.randomUUID(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(reviewId), eq(null))) + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); - - mockMvc.perform(get("/v1/reviews") - .param("reviewId", reviewId.toString())) + mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) .andExpect(status().isForbidden()); } - @Test - void getReviews_reviewId_없는_리뷰_404_예외전파() throws Exception { + void getReviews_by_reviewId_not_found_propagates_404() throws Exception { UUID reviewId = UUID.randomUUID(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(reviewId), eq(null))) + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - - mockMvc.perform(get("/v1/reviews") - .param("reviewId", reviewId.toString())) + mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) .andExpect(status().isNotFound()); } - @Test - void getReviews_storeId_필터_성공() throws Exception { + void getReviews_by_storeId_success() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); - ReviewResponse reviewResponse = ReviewResponse.builder() - .reviewId(reviewId) - .userId(1L) - .storeId(storeId) - .rating(3) - .content("보통이에요") - .build(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(null), eq(storeId))) - .thenReturn(List.of(reviewResponse)); - - mockMvc.perform(get("/v1/reviews") - .param("storeId", storeId.toString())) + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId).rating(3).content("ok").build(); + Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) + .thenReturn(page); + mockMvc.perform(get("/v1/reviews").param("storeId", storeId.toString())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(1))) - .andExpect(jsonPath("$.data[0].storeId").value(storeId.toString())) - .andExpect(jsonPath("$.data[0].userId").value(1)) - .andExpect(jsonPath("$.data[0].rating").value(3)) - .andExpect(jsonPath("$.data[0].content").value("보통이에요")); - - verify(reviewService, times(1)).getReviews(any(UserPrincipal.class), eq(null), eq(storeId)); + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.content[0].rating").value(3)); + verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); } - @Test - void getReviews_storeId_필터_결과없음_빈목록_반환() throws Exception { + void getReviews_by_storeId_empty() throws Exception { UUID storeId = UUID.randomUUID(); - - when(reviewService.getReviews(any(UserPrincipal.class), eq(null), eq(storeId))) - .thenReturn(Collections.emptyList()); - - mockMvc.perform(get("/v1/reviews") - .param("storeId", storeId.toString())) + Page emptyPage = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) + .thenReturn(emptyPage); + mockMvc.perform(get("/v1/reviews").param("storeId", storeId.toString())) .andExpect(status().isOk()) - .andExpect(jsonPath("$.data", hasSize(0))); + .andExpect(jsonPath("$.data.content", hasSize(0))); } - - - // ─────────────────────────────────────────────── - // DELETE /v1/reviews/{reviewId} - 리뷰 삭제 - // ─────────────────────────────────────────────── - @Test - void deleteReview_성공() throws Exception { + void getReviews_invalid_page_size_returns_400() throws Exception { + // size=20 is not in allowed set {10, 30, 50} -> @AssertTrue fails -> 400 + mockMvc.perform(get("/v1/reviews").param("size", "20")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).getReviews(any(), any(), any()); + } + // DELETE /v1/reviews/{reviewId} + @Test + void deleteReview_success() throws Exception { UUID reviewId = UUID.randomUUID(); - - doNothing().when(reviewService).deleteReview(eq(reviewId), any(UserPrincipal.class)); - + doNothing().when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); mockMvc.perform(delete("/v1/reviews/" + reviewId)) .andExpect(status().isOk()) .andExpect(jsonPath("$.message").value("리뷰 삭제 성공")); - - verify(reviewService, times(1)).deleteReview(eq(reviewId), any(UserPrincipal.class)); + verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); } - @Test - void deleteReview_실패_작성자_불일치_예외전파() throws Exception { + void deleteReview_fail_not_owner_propagates_403() throws Exception { UUID reviewId = UUID.randomUUID(); - doThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)) - .when(reviewService).deleteReview(eq(reviewId), any(UserPrincipal.class)); - + .when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); mockMvc.perform(delete("/v1/reviews/" + reviewId)) .andExpect(status().isForbidden()); - - verify(reviewService, times(1)).deleteReview(eq(reviewId), any(UserPrincipal.class)); - } - - // ─────────────────────────────────────────────── - // 추가 예외 전파 케이스 - // ─────────────────────────────────────────────── - - @Test - void createReview_실패_가게없음_예외전파() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - when(reviewService.createReview(any(UserPrincipal.class), any())) - .thenThrow(new CustomException(ErrorCode.STORE_NOT_FOUND)); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 4, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isNotFound()); - - verify(reviewService, times(1)).createReview(any(UserPrincipal.class), any()); + verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); } - @Test - void createReview_실패_중복리뷰_예외전파() throws Exception { - // Order 도메인 연결 후 동일 orderId 재등록 시도 → REVIEW_ALREADY_EXISTS - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - when(reviewService.createReview(any(UserPrincipal.class), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS)); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 4, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isConflict()); - - verify(reviewService, times(1)).createReview(any(UserPrincipal.class), any()); - } - - @Test - void deleteReview_실패_존재하지_않는_리뷰_404_예외전파() throws Exception { + void deleteReview_fail_not_found_propagates_404() throws Exception { UUID reviewId = UUID.randomUUID(); - doThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)) - .when(reviewService).deleteReview(eq(reviewId), any(UserPrincipal.class)); - + .when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); mockMvc.perform(delete("/v1/reviews/" + reviewId)) .andExpect(status().isNotFound()); - - verify(reviewService, times(1)).deleteReview(eq(reviewId), any(UserPrincipal.class)); - } - - @Test - void updateReview_실패_존재하지_않는_리뷰_404_예외전파() throws Exception { - UUID reviewId = UUID.randomUUID(); - - when(reviewService.updateReview(any(UserPrincipal.class), eq(reviewId), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - - String requestBody = """ - { - "rating": 4, - "content": "맛있어요" - } - """; - - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isNotFound()); - - verify(reviewService, times(1)).updateReview(any(UserPrincipal.class), eq(reviewId), any()); - } - - @Test - void createReview_실패_서버내부오류_500_예외전파() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - - when(reviewService.createReview(any(UserPrincipal.class), any())) - .thenThrow(new RuntimeException("예상치 못한 오류")); - - String requestBody = String.format(""" - { - "orderId": "%s", - "storeId": "%s", - "rating": 4, - "content": "맛있어요" - } - """, orderId, storeId); - - mockMvc.perform(post("/v1/reviews") - .contentType(MediaType.APPLICATION_JSON) - .content(requestBody)) - .andExpect(status().isInternalServerError()); + verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); } -} - - +} \ No newline at end of file diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java index ee0fe82..af7e0b9 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java @@ -1,5 +1,4 @@ package com.babjo.deliverycommerce.domain.review.service; - import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; @@ -25,696 +24,481 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; - +import org.springframework.data.domain.Pageable; import java.util.List; import java.util.Optional; import java.util.UUID; - 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.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; - - @ExtendWith(MockitoExtension.class) class ReviewServiceTest { - - @Mock - private ReviewRepository reviewRepository; - - @Mock - private StoreRepository storeRepository; - - @Mock - private UserRepository userRepository; - - @Mock - private ReviewMapper reviewMapper; - + @Mock private ReviewRepository reviewRepository; + @Mock private StoreRepository storeRepository; + @Mock private UserRepository userRepository; + @Mock private ReviewMapper reviewMapper; @InjectMocks private ReviewService reviewService; - - // 팀룰: 서비스는 UserPrincipal 없이 userId(Long) + role(String) 으로 동작 - private static final Long CUSTOMER_ID = 1L; - private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; - - private static final Long OTHER_ID = 2L; - private static final String OTHER_ROLE = "ROLE_CUSTOMER"; - - private static final Long MANAGER_ID = 3L; - private static final String MANAGER_ROLE = "ROLE_MANAGER"; - - private static final Long MASTER_ID = 4L; - private static final String MASTER_ROLE = "ROLE_MASTER"; - - private static final Long OWNER_ID = 5L; - private static final String OWNER_ROLE = "ROLE_OWNER"; - + private static final Long CUSTOMER_ID = 1L; + private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; + private static final Long OTHER_ID = 2L; + private static final String OTHER_ROLE = "ROLE_CUSTOMER"; + private static final Long MANAGER_ID = 3L; + private static final String MANAGER_ROLE = "ROLE_MANAGER"; + private static final Long MASTER_ID = 4L; + private static final String MASTER_ROLE = "ROLE_MASTER"; + private static final Long OWNER_ID = 5L; + private static final String OWNER_ROLE = "ROLE_OWNER"; private Store store; private User user; private Review review; - - /** 기본 ReviewSearchRequest (page=0, size=10, sortBy=createdAt, sortDir=desc) */ - private ReviewSearchRequest defaultSearch() { - return new ReviewSearchRequest(); - } - - /** reviewId 조건이 있는 ReviewSearchRequest */ - private ReviewSearchRequest searchByReviewId(UUID reviewId) { + private ReviewSearchRequest defaultSearch() { return new ReviewSearchRequest(); } + private ReviewSearchRequest searchByReviewId(UUID id) { ReviewSearchRequest s = new ReviewSearchRequest(); - s.setReviewId(reviewId); + s.setReviewId(id); return s; } - - /** storeId 조건이 있는 ReviewSearchRequest */ - private ReviewSearchRequest searchByStoreId(UUID storeId) { + private ReviewSearchRequest searchByStoreId(UUID id) { ReviewSearchRequest s = new ReviewSearchRequest(); - s.setStoreId(storeId); + s.setStoreId(id); return s; } - @BeforeEach void setUp() { - store = Store.create(1L, "한식", "테스트 식당", "서울시 강남구"); - user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); - review = Review.create(user, store, 4, "맛있어요"); - } - - // ───────────────────────────────────────────────────────────────── - // createReview - // ───────────────────────────────────────────────────────────────── - - @Test - void createReview_성공() { - ReviewCreateRequest request = new ReviewCreateRequest(); - ReviewCreateResponse expectedResponse = ReviewCreateResponse.builder() - .rating(4).content("맛있어요").build(); - + store = Store.create(1L, "korean", "Test Store", "Seoul"); + user = User.createForTest(1L, "testuser", "test@test.com", "tester", UserEnumRole.CUSTOMER); + review = Review.create(user, store, 4, "good"); + } + // CREATE + @Test + void createReview_success() { + ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateResponse expected = ReviewCreateResponse.builder().rating(4).content("good").build(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); - given(reviewMapper.toCreateResponse(any())).willReturn(expectedResponse); - - ReviewCreateResponse result = reviewService.createReview(CUSTOMER_ID, request); - + given(reviewMapper.toCreateResponse(any())).willReturn(expected); + ReviewCreateResponse result = reviewService.createReview(CUSTOMER_ID, req); assertThat(result.getRating()).isEqualTo(4); - assertThat(result.getContent()).isEqualTo("맛있어요"); + assertThat(result.getContent()).isEqualTo("good"); verify(reviewRepository, times(1)).save(any()); } - @Test - void createReview_성공_store_통계_갱신됨() { - ReviewCreateRequest request = new ReviewCreateRequest(); - ReviewCreateResponse expectedResponse = ReviewCreateResponse.builder() - .rating(4).content("맛있어요").build(); - + void createReview_store_stats_updated() { + ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateResponse expected = ReviewCreateResponse.builder().rating(4).content("good").build(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); - given(reviewMapper.toCreateResponse(any())).willReturn(expectedResponse); - - reviewService.createReview(CUSTOMER_ID, request); - + given(reviewMapper.toCreateResponse(any())).willReturn(expected); + reviewService.createReview(CUSTOMER_ID, req); assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(4.0); verify(storeRepository, times(1)).save(store); } - @Test - void createReview_실패_존재하지_않는_유저() { - ReviewCreateRequest request = new ReviewCreateRequest(); + void createReview_fail_user_not_found() { + ReviewCreateRequest req = new ReviewCreateRequest(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.USER_NOT_FOUND)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } - @Test - void createReview_실패_존재하지_않는_가게() { - ReviewCreateRequest request = new ReviewCreateRequest(); + void createReview_fail_store_not_found() { + ReviewCreateRequest req = new ReviewCreateRequest(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.STORE_NOT_FOUND)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.STORE_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } - @Test - void createReview_실패_소프트삭제된_가게() { - ReviewCreateRequest request = new ReviewCreateRequest(); + void createReview_fail_soft_deleted_store() { + ReviewCreateRequest req = new ReviewCreateRequest(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.STORE_NOT_FOUND)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.STORE_NOT_FOUND)); verify(storeRepository, never()).save(any()); verify(reviewRepository, never()).save(any()); } - - // ───────────────────────────────────────────────────────────────── - // getReviews (단건 조회 — reviewId 조건) - // ───────────────────────────────────────────────────────────────── - + // READ - single by reviewId @Test - void getReviews_CUSTOMER_본인_리뷰_단건조회_성공() { + void getReviews_customer_own_review_by_reviewId() { UUID reviewId = UUID.randomUUID(); - ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - + ReviewResponse expected = ReviewResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId)); - assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getReviewId()).isEqualTo(reviewId); assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); - verify(reviewRepository, times(1)).findByReviewId(reviewId); } - @Test - void getReviews_CUSTOMER_타인_리뷰_단건조회_실패() { + void getReviews_customer_others_review_forbidden() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - - // review 작성자 userId=1L, 요청자 OTHER_ID=2L assertThatThrownBy(() -> reviewService.getReviews(OTHER_ID, OTHER_ROLE, searchByReviewId(reviewId))) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); } - @Test - void getReviews_MANAGER_타인_리뷰_단건조회_성공() { + void getReviews_manager_can_read_others_review() { UUID reviewId = UUID.randomUUID(); - ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - + ReviewResponse expected = ReviewResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, searchByReviewId(reviewId)); - assertThat(result.getContent()).hasSize(1); - assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); } - @Test - void getReviews_MASTER_타인_리뷰_단건조회_성공() { + void getReviews_master_can_read_others_review() { UUID reviewId = UUID.randomUUID(); - ReviewResponse expectedResponse = ReviewResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - + ReviewResponse expected = ReviewResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toResponse(review)).willReturn(expectedResponse); - + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, searchByReviewId(reviewId)); - assertThat(result.getContent()).hasSize(1); } - @Test - void getReviews_단건조회_실패_존재하지_않는_리뷰() { + void getReviews_reviewId_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId))) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } - - // ───────────────────────────────────────────────────────────────── - // getReviews (가게별 목록 조회 — storeId 조건) - // ───────────────────────────────────────────────────────────────── - + // READ - list by storeId @Test - void getReviews_storeId_필터_목록조회_성공_CUSTOMER() { + void getReviews_by_storeId_success() { UUID storeId = UUID.randomUUID(); - ReviewResponse reviewResponse = ReviewResponse.builder() - .storeId(storeId).userId(CUSTOMER_ID).rating(3).content("보통이에요").build(); - - Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - given(reviewRepository.findAllByStore_StoreId(eq(storeId), any())).willReturn(reviewPage); - given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - + ReviewResponse expected = ReviewResponse.builder() + .storeId(storeId).userId(CUSTOMER_ID).rating(3).content("ok").build(); + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any(Pageable.class))).willReturn(page); + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); - assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getStoreId()).isEqualTo(storeId); - verify(reviewRepository, times(1)).findAllByStore_StoreId(eq(storeId), any()); } - @Test - void getReviews_storeId_필터_결과없음_빈목록_반환() { + void getReviews_by_storeId_empty() { UUID storeId = UUID.randomUUID(); Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); - given(reviewRepository.findAllByStore_StoreId(eq(storeId), any())).willReturn(emptyPage); - + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any(Pageable.class))).willReturn(emptyPage); Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); - assertThat(result.getContent()).isEmpty(); } - - // ───────────────────────────────────────────────────────────────── - // getReviews (파라미터 없음 — 권한별 전체/본인) - // ───────────────────────────────────────────────────────────────── - - @Test - void getReviews_파라미터없음_CUSTOMER_본인_리뷰만_반환() { - ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - - Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any())).willReturn(reviewPage); - given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - + // READ - no params (role-based) + @Test + void getReviews_customer_no_params_returns_own() { + ReviewResponse expected = ReviewResponse.builder().userId(CUSTOMER_ID).rating(4).content("good").build(); + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class))).willReturn(page); + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); - assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); - verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any()); - verify(reviewRepository, never()).findAll(any()); + verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class)); + verify(reviewRepository, never()).findAll(any(Pageable.class)); } - @Test - void getReviews_파라미터없음_CUSTOMER_본인_리뷰_없으면_빈목록() { + void getReviews_customer_no_params_empty() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); - given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any())).willReturn(emptyPage); - + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class))).willReturn(emptyPage); Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); - assertThat(result.getContent()).isEmpty(); - verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any()); - verify(reviewRepository, never()).findAll(any()); + verify(reviewRepository, never()).findAll(any(Pageable.class)); } - @Test - void getReviews_파라미터없음_OWNER_본인_리뷰만_반환() { + void getReviews_owner_no_params_returns_own() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); - given(reviewRepository.findAllByUser_UserId(eq(OWNER_ID), any())).willReturn(emptyPage); - + given(reviewRepository.findAllByUser_UserId(eq(OWNER_ID), any(Pageable.class))).willReturn(emptyPage); Page result = reviewService.getReviews(OWNER_ID, OWNER_ROLE, defaultSearch()); - assertThat(result.getContent()).isEmpty(); - verify(reviewRepository, times(1)).findAllByUser_UserId(eq(OWNER_ID), any()); - verify(reviewRepository, never()).findAll(any()); - } - - @Test - void getReviews_파라미터없음_MANAGER_전체_리뷰_반환() { - ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(CUSTOMER_ID).rating(4).content("맛있어요").build(); - - Page reviewPage = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); - given(reviewRepository.findAll(any(org.springframework.data.domain.Pageable.class))).willReturn(reviewPage); - given(reviewMapper.toResponse(review)).willReturn(reviewResponse); - + verify(reviewRepository, times(1)).findAllByUser_UserId(eq(OWNER_ID), any(Pageable.class)); + verify(reviewRepository, never()).findAll(any(Pageable.class)); + } + @Test + void getReviews_manager_no_params_returns_all() { + ReviewResponse expected = ReviewResponse.builder().userId(CUSTOMER_ID).rating(4).content("good").build(); + Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAll(any(Pageable.class))).willReturn(page); + given(reviewMapper.toResponse(review)).willReturn(expected); Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, defaultSearch()); - assertThat(result.getContent()).hasSize(1); - verify(reviewRepository, times(1)).findAll(any(org.springframework.data.domain.Pageable.class)); - verify(reviewRepository, never()).findAllByUser_UserId(any(), any()); + verify(reviewRepository, times(1)).findAll(any(Pageable.class)); + verify(reviewRepository, never()).findAllByUser_UserId(any(), any(Pageable.class)); } - @Test - void getReviews_파라미터없음_MASTER_전체_리뷰_반환() { + void getReviews_master_no_params_returns_all() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); - given(reviewRepository.findAll(any(org.springframework.data.domain.Pageable.class))).willReturn(emptyPage); - + given(reviewRepository.findAll(any(Pageable.class))).willReturn(emptyPage); Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, defaultSearch()); - assertThat(result.getContent()).isEmpty(); - verify(reviewRepository, times(1)).findAll(any(org.springframework.data.domain.Pageable.class)); - verify(reviewRepository, never()).findAllByUser_UserId(any(), any()); + verify(reviewRepository, times(1)).findAll(any(Pageable.class)); + verify(reviewRepository, never()).findAllByUser_UserId(any(), any(Pageable.class)); } - - // ───────────────────────────────────────────────────────────────── - // updateReview - // ───────────────────────────────────────────────────────────────── - + // UPDATE @Test - void updateReview_성공() { + void updateReview_success() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(5); - given(request.getContent()).willReturn("정말 맛있어요"); - - ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(5).content("정말 맛있어요").build(); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(5); + given(req.getContent()).willReturn("very good"); + ReviewUpdateResponse expected = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(5).content("very good").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - - ReviewUpdateResponse result = reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - + given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); assertThat(result.getReviewId()).isEqualTo(reviewId); assertThat(result.getRating()).isEqualTo(5); - assertThat(result.getContent()).isEqualTo("정말 맛있어요"); } - @Test - void updateReview_실패_작성자_불일치() { + void updateReview_fail_not_owner() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - - // review 작성자 userId=1L, OTHER_ID=2L - assertThatThrownBy(() -> reviewService.updateReview(OTHER_ID, OTHER_ROLE, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(OTHER_ID, OTHER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } - @Test - void updateReview_성공_MANAGER는_타인_리뷰_수정_가능() { + void updateReview_manager_can_update_others() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(3); - given(request.getContent()).willReturn("관리자 수정"); - - ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(3).content("관리자 수정").build(); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(3); + given(req.getContent()).willReturn("manager edit"); + ReviewUpdateResponse expected = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(3).content("manager edit").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - - ReviewUpdateResponse result = reviewService.updateReview(MANAGER_ID, MANAGER_ROLE, reviewId, request); - + given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(MANAGER_ID, MANAGER_ROLE, reviewId, req); assertThat(result.getRating()).isEqualTo(3); } - @Test - void updateReview_성공_MASTER는_타인_리뷰_수정_가능() { + void updateReview_master_can_update_others() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(2); - given(request.getContent()).willReturn("마스터 수정"); - - ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(2).content("마스터 수정").build(); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(2); + given(req.getContent()).willReturn("master edit"); + ReviewUpdateResponse expected = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(2).content("master edit").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - - ReviewUpdateResponse result = reviewService.updateReview(MASTER_ID, MASTER_ROLE, reviewId, request); - + given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(MASTER_ID, MASTER_ROLE, reviewId, req); assertThat(result.getRating()).isEqualTo(2); } - @Test - void updateReview_실패_OWNER는_타인_리뷰_수정_불가() { + void updateReview_fail_owner_cannot_update_others() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - - assertThatThrownBy(() -> reviewService.updateReview(OWNER_ID, OWNER_ROLE, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(OWNER_ID, OWNER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); } - @Test - void updateReview_별점_변경_시_store_통계_갱신됨() { + void updateReview_rating_change_updates_store_stats() { UUID reviewId = UUID.randomUUID(); store.addReview(4); // count=1, avg=4.0 - - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(2); - given(request.getContent()).willReturn("별로에요"); - - ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(2).content("별로에요").build(); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(2); + given(req.getContent()).willReturn("bad"); + ReviewUpdateResponse expected = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(2).content("bad").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - - reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - - // 4 → 2 변경: avg = 4.0 + (2 - 4) / 1 = 2.0 + given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(2.0); verify(storeRepository, times(1)).save(store); } - @Test - void updateReview_별점_동일하면_store_저장_생략() { + void updateReview_same_rating_skips_store_save() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(4); // oldRating == newRating - given(request.getContent()).willReturn("내용만 수정"); - - ReviewUpdateResponse expectedResponse = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(4).content("내용만 수정").build(); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(4); + given(req.getContent()).willReturn("content only"); + ReviewUpdateResponse expected = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(4).content("content only").build(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - given(reviewMapper.toUpdateResponse(review)).willReturn(expectedResponse); - - reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request); - + given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); verify(storeRepository, never()).save(any()); } - @Test - void updateReview_실패_존재하지_않는_리뷰() { + void updateReview_fail_review_not_found() { UUID reviewId = UUID.randomUUID(); - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - - assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request)) + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } - - // ───────────────────────────────────────────────────────────────── - // deleteReview - // ───────────────────────────────────────────────────────────────── - + // DELETE (Soft Delete) @Test - void deleteReview_성공() { + void deleteReview_success() { UUID reviewId = UUID.randomUUID(); + store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); - assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(CUSTOMER_ID); verify(reviewRepository, times(1)).save(review); } - @Test - void deleteReview_실패_작성자_불일치() { + void deleteReview_fail_not_owner() { UUID reviewId = UUID.randomUUID(); - // review 작성자 userId=1L, OTHER_ID=2L given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OTHER_ID, OTHER_ROLE)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } - @Test - void deleteReview_성공_MANAGER는_타인_리뷰_삭제_가능() { + void deleteReview_manager_can_delete_others() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - reviewService.deleteReview(reviewId, MANAGER_ID, MANAGER_ROLE); - assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(MANAGER_ID); } - @Test - void deleteReview_성공_MASTER는_타인_리뷰_삭제_가능() { + void deleteReview_master_can_delete_others() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - reviewService.deleteReview(reviewId, MASTER_ID, MASTER_ROLE); - assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(MASTER_ID); } - @Test - void deleteReview_실패_OWNER는_타인_리뷰_삭제_불가() { + void deleteReview_fail_owner_cannot_delete_others() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OWNER_ID, OWNER_ROLE)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } - @Test - void deleteReview_이미_소프트삭제된_리뷰는_REVIEW_NOT_FOUND_반환() { + void deleteReview_already_soft_deleted_returns_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); verify(storeRepository, never()).save(any()); verify(reviewRepository, never()).save(any()); } - @Test - void deleteReview_성공_store_통계_갱신됨() { + void deleteReview_store_stats_updated() { UUID reviewId = UUID.randomUUID(); - store.addReview(4); // count=1, avg=4.0 + store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); - assertThat(store.getReviewCount()).isEqualTo(0); assertThat(store.getAverageRating()).isEqualTo(0.0); verify(storeRepository, times(1)).save(store); } - @Test - void deleteReview_실패_존재하지_않는_리뷰() { + void deleteReview_fail_review_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); - assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) - .satisfies(e -> assertThat(((CustomException) e).getErrorCode()) - .isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); - + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } - - // ───────────────────────────────────────────────────────────────── - // 트랜잭션 롤백 검증 - // ───────────────────────────────────────────────────────────────── - + // Transaction rollback @Test - void createReview_트랜잭션_롤백_reviewRepository_save_예외시_store_통계_미반영() { - ReviewCreateRequest request = new ReviewCreateRequest(); - + void createReview_rollback_when_reviewRepository_save_throws() { + ReviewCreateRequest req = new ReviewCreateRequest(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); - given(reviewRepository.save(any())).willThrow(new RuntimeException("DB 저장 오류")); - - assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) + given(reviewRepository.save(any())).willThrow(new RuntimeException("db error")); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(RuntimeException.class) - .hasMessage("DB 저장 오류"); - + .hasMessage("db error"); verify(storeRepository, never()).save(store); } - @Test - void createReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { - ReviewCreateRequest request = new ReviewCreateRequest(); - + void createReview_rollback_when_storeRepository_save_throws() { + ReviewCreateRequest req = new ReviewCreateRequest(); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); - doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(store); - - assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, request)) + doThrow(new RuntimeException("store error")).when(storeRepository).save(store); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(RuntimeException.class) - .hasMessage("store 저장 오류"); + .hasMessage("store error"); } - @Test - void updateReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { + void updateReview_rollback_when_storeRepository_save_throws() { UUID reviewId = UUID.randomUUID(); store.addReview(4); - - ReviewUpdateRequest request = mock(ReviewUpdateRequest.class); - given(request.getRating()).willReturn(2); - given(request.getContent()).willReturn("별로에요"); - + ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); + given(req.getRating()).willReturn(2); + given(req.getContent()).willReturn("bad"); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(any()); - - assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, request)) + doThrow(new RuntimeException("store error")).when(storeRepository).save(any()); + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req)) .isInstanceOf(RuntimeException.class) - .hasMessage("store 저장 오류"); + .hasMessage("store error"); } - @Test - void deleteReview_트랜잭션_롤백_storeRepository_save_예외시_예외_전파() { + void deleteReview_rollback_when_storeRepository_save_throws() { UUID reviewId = UUID.randomUUID(); store.addReview(4); - given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); - doThrow(new RuntimeException("store 저장 오류")).when(storeRepository).save(store); - + doThrow(new RuntimeException("store error")).when(storeRepository).save(store); assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(RuntimeException.class) - .hasMessage("store 저장 오류"); - + .hasMessage("store error"); verify(reviewRepository, never()).save(any()); } - @Test - void deleteReview_트랜잭션_롤백_reviewRepository_save_예외시_예외_전파() { + void deleteReview_rollback_when_reviewRepository_save_throws() { UUID reviewId = UUID.randomUUID(); store.addReview(4); - given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(storeRepository.save(store)).willReturn(store); - doThrow(new RuntimeException("review 저장 오류")).when(reviewRepository).save(review); - + doThrow(new RuntimeException("review error")).when(reviewRepository).save(review); assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(RuntimeException.class) - .hasMessage("review 저장 오류"); + .hasMessage("review error"); } - - // ───────────────────────────────────────────────────────────────── - // ErrorCode 존재 검증 (Order 연동 대기 항목 선검증) - // ───────────────────────────────────────────────────────────────── - + // ErrorCode pre-validation @Test - void REVIEW_ALREADY_EXISTS_에러코드가_정의되어_있음() { + void errorCode_REVIEW_ALREADY_EXISTS_is_defined() { assertThat(ErrorCode.REVIEW_ALREADY_EXISTS).isNotNull(); assertThat(ErrorCode.REVIEW_ALREADY_EXISTS.name()).isEqualTo("REVIEW_ALREADY_EXISTS"); } - @Test - void createReview_중복검증_REVIEW_ALREADY_EXISTS_예외_발생_가능() { + void errorCode_REVIEW_ALREADY_EXISTS_is_conflict_409() { CustomException ex = new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); - - assertThat(ex.getErrorCode()).isEqualTo(ErrorCode.REVIEW_ALREADY_EXISTS); assertThat(ex.getErrorCode().getStatus().value()).isEqualTo(409); - assertThat(ex.getMessage()).isEqualTo("이미 리뷰를 작성했습니다."); } - @Test - void ORDER_NOT_FOUND_에러코드가_정의되어_있음() { + void errorCode_ORDER_NOT_FOUND_is_defined() { assertThat(ErrorCode.ORDER_NOT_FOUND).isNotNull(); - assertThat(ErrorCode.ORDER_NOT_FOUND.name()).isEqualTo("ORDER_NOT_FOUND"); assertThat(ErrorCode.ORDER_NOT_FOUND.getStatus().value()).isEqualTo(404); } -} - +} \ No newline at end of file From 67985546c52d6fcb9fa855f3fa245f1161204c67 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 11:34:33 +0900 Subject: [PATCH 08/15] feat: refactor ReviewControllerTest to use nested classes for better organization and clarity --- .../controller/ReviewControllerTest.java | 904 ++++++++++++------ 1 file changed, 606 insertions(+), 298 deletions(-) diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java index c42b4cb..e398477 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java @@ -1,7 +1,7 @@ package com.babjo.deliverycommerce.domain.review.controller; + import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; -import com.babjo.deliverycommerce.domain.review.dto.ReviewSearchRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewUpdateResponse; import com.babjo.deliverycommerce.domain.review.service.ReviewService; import com.babjo.deliverycommerce.global.exception.CustomException; @@ -10,10 +10,13 @@ import com.babjo.deliverycommerce.global.security.CurrentUserResolver; import com.babjo.deliverycommerce.global.security.UserPrincipal; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; import org.mockito.Mock; -import org.mockito.MockitoAnnotations; +import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -21,334 +24,639 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.RequestPostProcessor; import org.springframework.test.web.servlet.setup.MockMvcBuilders; + import java.time.LocalDateTime; import java.util.Collections; import java.util.List; import java.util.UUID; -import static org.hamcrest.Matchers.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; + +import static org.hamcrest.Matchers.hasSize; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + /** - * ReviewController unit test - * Team rule: CurrentUserResolver injected as Mock to extract userId - * Team rule: ReviewService called with Long userId + String role (not UserPrincipal) - * Response type: Page + * ReviewController 단위 테스트 + * + * [핵심 설계 결정] + * - standaloneSetup: Spring Context 없이 Controller 레이어만 격리 테스트 + * - 인증 주입: MockHttpServletRequest의 userPrincipal을 직접 세팅하는 RequestPostProcessor를 + * 사용하여 Spring MVC가 Authentication 파라미터를 올바르게 주입하도록 처리 + * - GlobalExceptionHandler: setControllerAdvice()로 명시 등록하여 예외 → HTTP 상태 변환 검증 */ +@ExtendWith(MockitoExtension.class) class ReviewControllerTest { + private MockMvc mockMvc; - @Mock - private ReviewService reviewService; - @Mock - private CurrentUserResolver currentUserResolver; + + @Mock private ReviewService reviewService; + @Mock private CurrentUserResolver currentUserResolver; + @InjectMocks private ReviewController reviewController; + private static final Long CUSTOMER_ID = 1L; private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; + + /** 각 MockMvc 요청에 Authentication을 주입하는 RequestPostProcessor */ + private RequestPostProcessor withCustomerAuth; + @BeforeEach void setUp() { - MockitoAnnotations.openMocks(this); mockMvc = MockMvcBuilders.standaloneSetup(reviewController) .setControllerAdvice(new GlobalExceptionHandler()) .build(); + UserPrincipal customerPrincipal = new UserPrincipal(CUSTOMER_ID, "testuser", CUSTOMER_ROLE); - UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken( + Authentication customerAuth = new UsernamePasswordAuthenticationToken( customerPrincipal, null, List.of(new SimpleGrantedAuthority(CUSTOMER_ROLE)) ); - SecurityContextHolder.getContext().setAuthentication(auth); - when(currentUserResolver.getUserId(any(Authentication.class))).thenReturn(CUSTOMER_ID); + + // MockHttpServletRequest에 직접 UserPrincipal을 세팅 + // → Spring MVC HandlerMethodArgumentResolver가 Authentication 파라미터로 변환해 주입 + withCustomerAuth = request -> { + request.setUserPrincipal(customerAuth); + return request; + }; + + lenient().when(currentUserResolver.getUserId(any(Authentication.class))) + .thenReturn(CUSTOMER_ID); } + + // ───────────────────────────────────────────────────────────────── // POST /v1/reviews - @Test - void createReview_success() throws Exception { - UUID orderId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).orderId(orderId).storeId(storeId) - .rating(4).content("good").createdAt(LocalDateTime.now()).build(); - when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", orderId, storeId); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) - .andExpect(jsonPath("$.data.userId").value(1)) - .andExpect(jsonPath("$.data.rating").value(4)) - .andExpect(jsonPath("$.data.content").value("good")); - verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); - } - @Test - void createReview_boundary_rating_min_1() throws Exception { - UUID storeId = UUID.randomUUID(); - UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) - .rating(1).content("bad").createdAt(LocalDateTime.now()).build(); - when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":1,\"content\":\"bad\"}", UUID.randomUUID(), storeId); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.data.rating").value(1)); - } - @Test - void createReview_boundary_rating_max_5() throws Exception { - UUID storeId = UUID.randomUUID(); - UUID reviewId = UUID.randomUUID(); - ReviewCreateResponse response = ReviewCreateResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) - .rating(5).content("great").createdAt(LocalDateTime.now()).build(); - when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":5,\"content\":\"great\"}", UUID.randomUUID(), storeId); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.data.rating").value(5)); - } - @Test - void createReview_validation_fail_missing_required_fields() throws Exception { - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content("{}")) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); - } - @Test - void createReview_validation_fail_rating_above_5() throws Exception { - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":6,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); - } - @Test - void createReview_validation_fail_rating_below_1() throws Exception { - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":0,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); - } - @Test - void createReview_validation_fail_content_blank() throws Exception { - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":3,\"content\":\" \"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); - } - @Test - void createReview_validation_fail_content_null() throws Exception { - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":3}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).createReview(any(), any()); - } - @Test - void createReview_fail_user_not_found_propagates_404() throws Exception { - when(reviewService.createReview(eq(CUSTOMER_ID), any())) - .thenThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isNotFound()); - } - @Test - void createReview_fail_store_not_found_propagates_404() throws Exception { - when(reviewService.createReview(eq(CUSTOMER_ID), any())) - .thenThrow(new CustomException(ErrorCode.STORE_NOT_FOUND)); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isNotFound()); - verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); - } - @Test - void createReview_fail_duplicate_review_propagates_409() throws Exception { - when(reviewService.createReview(eq(CUSTOMER_ID), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS)); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isConflict()); - verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); - } - @Test - void createReview_fail_unexpected_error_returns_500() throws Exception { - when(reviewService.createReview(eq(CUSTOMER_ID), any())) - .thenThrow(new RuntimeException("unexpected")); - String body = String.format("{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", UUID.randomUUID(), UUID.randomUUID()); - mockMvc.perform(post("/v1/reviews").contentType(MediaType.APPLICATION_JSON).content(body)) - .andExpect(status().isInternalServerError()); + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("POST /v1/reviews — 리뷰 생성") + class CreateReview { + + @Test + @DisplayName("성공 — 정상 요청 시 201과 응답 바디 반환") + void createReview_success() throws Exception { + UUID storeId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + ReviewCreateResponse response = ReviewCreateResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(4).content("good").createdAt(LocalDateTime.now()).build(); + + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", storeId))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) + .andExpect(jsonPath("$.data.userId").value(CUSTOMER_ID.intValue())) + .andExpect(jsonPath("$.data.rating").value(4)) + .andExpect(jsonPath("$.data.content").value("good")); + + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + + @Test + @DisplayName("성공 — rating 경계값 최솟값 1") + void createReview_boundary_rating_min_1() throws Exception { + UUID storeId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + ReviewCreateResponse response = ReviewCreateResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(1).content("bad").createdAt(LocalDateTime.now()).build(); + + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":1,\"content\":\"bad\"}", storeId))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.rating").value(1)); + } + + @Test + @DisplayName("성공 — rating 경계값 최댓값 5") + void createReview_boundary_rating_max_5() throws Exception { + UUID storeId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + ReviewCreateResponse response = ReviewCreateResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(5).content("great").createdAt(LocalDateTime.now()).build(); + + when(reviewService.createReview(eq(CUSTOMER_ID), any())).thenReturn(response); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":5,\"content\":\"great\"}", storeId))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.rating").value(5)); + } + + @Test + @DisplayName("검증 실패 — 필수 필드 누락 시 400") + void createReview_validation_fail_missing_required_fields() throws Exception { + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("검증 실패 — rating 6 초과 시 400") + void createReview_validation_fail_rating_above_5() throws Exception { + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":6,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("검증 실패 — rating 0 이하 시 400") + void createReview_validation_fail_rating_below_1() throws Exception { + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":0,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("검증 실패 — content 공백 시 400") + void createReview_validation_fail_content_blank() throws Exception { + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":3,\"content\":\" \"}", + UUID.randomUUID()))) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("검증 실패 — content null 시 400") + void createReview_validation_fail_content_null() throws Exception { + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":3}", UUID.randomUUID()))) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).createReview(any(), any()); + } + + @Test + @DisplayName("실패 전파 — USER_NOT_FOUND → 404") + void createReview_fail_user_not_found_propagates_404() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.USER_NOT_FOUND)); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isNotFound()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + + @Test + @DisplayName("실패 전파 — STORE_NOT_FOUND → 404") + void createReview_fail_store_not_found_propagates_404() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.STORE_NOT_FOUND)); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isNotFound()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + + @Test + @DisplayName("실패 전파 — REVIEW_ALREADY_EXISTS → 409") + void createReview_fail_duplicate_review_propagates_409() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS)); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isConflict()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + + @Test + @DisplayName("실패 전파 — 예상치 못한 예외 → 500") + void createReview_fail_unexpected_error_returns_500() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new RuntimeException("unexpected")); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID()))) + .andExpect(status().isInternalServerError()); + } } + + // ───────────────────────────────────────────────────────────────── // PUT /v1/reviews/{reviewId} - @Test - void updateReview_success() throws Exception { - UUID reviewId = UUID.randomUUID(); - ReviewUpdateResponse response = ReviewUpdateResponse.builder() - .reviewId(reviewId).rating(5).content("very good").updatedAt(LocalDateTime.now()).build(); - when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) - .thenReturn(response); - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"rating\":5,\"content\":\"very good\"}")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) - .andExpect(jsonPath("$.data.rating").value(5)) - .andExpect(jsonPath("$.data.content").value("very good")); - verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); - } - @Test - void updateReview_fail_not_owner_propagates_403() throws Exception { - UUID reviewId = UUID.randomUUID(); - when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"rating\":5,\"content\":\"good\"}")) - .andExpect(status().isForbidden()); - verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); - } - @Test - void updateReview_validation_fail_rating_below_1() throws Exception { - UUID reviewId = UUID.randomUUID(); - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"rating\":0,\"content\":\"bad\"}")) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).updateReview(any(), any(), any(), any()); - } - @Test - void updateReview_validation_fail_rating_above_5() throws Exception { - UUID reviewId = UUID.randomUUID(); - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"rating\":6,\"content\":\"too much\"}")) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).updateReview(any(), any(), any(), any()); - } - @Test - void updateReview_fail_review_not_found_propagates_404() throws Exception { - UUID reviewId = UUID.randomUUID(); - when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) - .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - mockMvc.perform(put("/v1/reviews/" + reviewId) - .contentType(MediaType.APPLICATION_JSON) - .content("{\"rating\":4,\"content\":\"good\"}")) - .andExpect(status().isNotFound()); - verify(reviewService, times(1)).updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("PUT /v1/reviews/{reviewId} — 리뷰 수정") + class UpdateReview { + + @Test + @DisplayName("성공 — 본인 리뷰 수정 시 200과 응답 바디 반환") + void updateReview_success() throws Exception { + UUID reviewId = UUID.randomUUID(); + ReviewUpdateResponse response = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(5).content("very good") + .updatedAt(LocalDateTime.now()).build(); + + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenReturn(response); + + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":5,\"content\":\"very good\"}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) + .andExpect(jsonPath("$.data.rating").value(5)) + .andExpect(jsonPath("$.data.content").value("very good")); + + verify(reviewService, times(1)) + .updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); + } + + @Test + @DisplayName("실패 전파 — 권한 없음 → 403") + void updateReview_fail_not_owner_propagates_403() throws Exception { + UUID reviewId = UUID.randomUUID(); + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); + + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":5,\"content\":\"good\"}")) + .andExpect(status().isForbidden()); + + verify(reviewService, times(1)) + .updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); + } + + @Test + @DisplayName("검증 실패 — rating 0 이하 시 400") + void updateReview_validation_fail_rating_below_1() throws Exception { + UUID reviewId = UUID.randomUUID(); + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":0,\"content\":\"bad\"}")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).updateReview(any(), any(), any(), any()); + } + + @Test + @DisplayName("검증 실패 — rating 6 초과 시 400") + void updateReview_validation_fail_rating_above_5() throws Exception { + UUID reviewId = UUID.randomUUID(); + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":6,\"content\":\"too much\"}")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).updateReview(any(), any(), any(), any()); + } + + @Test + @DisplayName("실패 전파 — REVIEW_NOT_FOUND → 404") + void updateReview_fail_review_not_found_propagates_404() throws Exception { + UUID reviewId = UUID.randomUUID(); + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); + + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":4,\"content\":\"good\"}")) + .andExpect(status().isNotFound()); + + verify(reviewService, times(1)) + .updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any()); + } + + @Test + @DisplayName("성공 — rating만 수정 (content 미포함)") + void updateReview_only_rating_changed() throws Exception { + UUID reviewId = UUID.randomUUID(); + ReviewUpdateResponse response = ReviewUpdateResponse.builder() + .reviewId(reviewId).rating(3).content("기존 내용") + .updatedAt(LocalDateTime.now()).build(); + + when(reviewService.updateReview(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), eq(reviewId), any())) + .thenReturn(response); + + mockMvc.perform(put("/v1/reviews/" + reviewId) + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"rating\":3}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.rating").value(3)); + } } + + // ───────────────────────────────────────────────────────────────── // GET /v1/reviews - @Test - void getReviews_customer_no_params_returns_own_reviews() throws Exception { - ReviewResponse reviewResponse = ReviewResponse.builder() - .userId(CUSTOMER_ID).rating(4).content("good").build(); - Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenReturn(page); - mockMvc.perform(get("/v1/reviews")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content", hasSize(1))) - .andExpect(jsonPath("$.data.content[0].userId").value(CUSTOMER_ID)); - verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); - } - @Test - void getReviews_no_params_empty_list() throws Exception { - Page emptyPage = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenReturn(emptyPage); - mockMvc.perform(get("/v1/reviews")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content", hasSize(0))); - verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); - } - @Test - void getReviews_by_reviewId_success() throws Exception { - UUID reviewId = UUID.randomUUID(); - UUID storeId = UUID.randomUUID(); - ReviewResponse reviewResponse = ReviewResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId).rating(5).content("good").build(); - Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenReturn(page); - mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content", hasSize(1))) - .andExpect(jsonPath("$.data.content[0].reviewId").value(reviewId.toString())) - .andExpect(jsonPath("$.data.content[0].rating").value(5)); - verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); - } - @Test - void getReviews_by_reviewId_forbidden_propagates_403() throws Exception { - UUID reviewId = UUID.randomUUID(); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); - mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) - .andExpect(status().isForbidden()); - } - @Test - void getReviews_by_reviewId_not_found_propagates_404() throws Exception { - UUID reviewId = UUID.randomUUID(); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); - mockMvc.perform(get("/v1/reviews").param("reviewId", reviewId.toString())) - .andExpect(status().isNotFound()); - } - @Test - void getReviews_by_storeId_success() throws Exception { - UUID storeId = UUID.randomUUID(); - UUID reviewId = UUID.randomUUID(); - ReviewResponse reviewResponse = ReviewResponse.builder() - .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId).rating(3).content("ok").build(); - Page page = new PageImpl<>(List.of(reviewResponse), PageRequest.of(0, 10), 1); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenReturn(page); - mockMvc.perform(get("/v1/reviews").param("storeId", storeId.toString())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content", hasSize(1))) - .andExpect(jsonPath("$.data.content[0].storeId").value(storeId.toString())) - .andExpect(jsonPath("$.data.content[0].rating").value(3)); - verify(reviewService, times(1)).getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class)); - } - @Test - void getReviews_by_storeId_empty() throws Exception { - UUID storeId = UUID.randomUUID(); - Page emptyPage = new PageImpl<>(Collections.emptyList(), PageRequest.of(0, 10), 0); - when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any(ReviewSearchRequest.class))) - .thenReturn(emptyPage); - mockMvc.perform(get("/v1/reviews").param("storeId", storeId.toString())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.content", hasSize(0))); - } - @Test - void getReviews_invalid_page_size_returns_400() throws Exception { - // size=20 is not in allowed set {10, 30, 50} -> @AssertTrue fails -> 400 - mockMvc.perform(get("/v1/reviews").param("size", "20")) - .andExpect(status().isBadRequest()); - verify(reviewService, never()).getReviews(any(), any(), any()); + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("GET /v1/reviews — 리뷰 조회") + class GetReviews { + + @Test + @DisplayName("파라미터 없음 — CUSTOMER 본인 리뷰 목록 반환") + void getReviews_customer_no_params_returns_own_reviews() throws Exception { + ReviewResponse reviewResponse = ReviewResponse.builder() + .userId(CUSTOMER_ID).rating(4).content("good").build(); + Page page = new PageImpl<>( + List.of(reviewResponse), PageRequest.of(0, 10), 1); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(page); + + mockMvc.perform(get("/v1/reviews").with(withCustomerAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].userId").value(CUSTOMER_ID)); + + verify(reviewService, times(1)) + .getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any()); + } + + @Test + @DisplayName("파라미터 없음 — 빈 목록 반환") + void getReviews_no_params_empty_list() throws Exception { + Page emptyPage = new PageImpl<>( + Collections.emptyList(), PageRequest.of(0, 10), 0); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(emptyPage); + + mockMvc.perform(get("/v1/reviews").with(withCustomerAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(0))); + } + + @Test + @DisplayName("reviewId 파라미터 — 단건 조회 성공") + void getReviews_by_reviewId_success() throws Exception { + UUID reviewId = UUID.randomUUID(); + UUID storeId = UUID.randomUUID(); + ReviewResponse reviewResponse = ReviewResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(5).content("good").build(); + Page page = new PageImpl<>( + List.of(reviewResponse), PageRequest.of(0, 10), 1); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(page); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("reviewId", reviewId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].reviewId").value(reviewId.toString())) + .andExpect(jsonPath("$.data.content[0].rating").value(5)); + } + + @Test + @DisplayName("reviewId 파라미터 — 타인 리뷰 조회 → 403") + void getReviews_by_reviewId_forbidden_propagates_403() throws Exception { + UUID reviewId = UUID.randomUUID(); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("reviewId", reviewId.toString())) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("reviewId 파라미터 — 없는 리뷰 조회 → 404") + void getReviews_by_reviewId_not_found_propagates_404() throws Exception { + UUID reviewId = UUID.randomUUID(); + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("reviewId", reviewId.toString())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("storeId 파라미터 — 가게별 목록 반환") + void getReviews_by_storeId_success() throws Exception { + UUID storeId = UUID.randomUUID(); + UUID reviewId = UUID.randomUUID(); + ReviewResponse reviewResponse = ReviewResponse.builder() + .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) + .rating(3).content("ok").build(); + Page page = new PageImpl<>( + List.of(reviewResponse), PageRequest.of(0, 10), 1); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(page); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("storeId", storeId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(1))) + .andExpect(jsonPath("$.data.content[0].storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.content[0].rating").value(3)); + } + + @Test + @DisplayName("storeId 파라미터 — 빈 목록 반환") + void getReviews_by_storeId_empty() throws Exception { + UUID storeId = UUID.randomUUID(); + Page emptyPage = new PageImpl<>( + Collections.emptyList(), PageRequest.of(0, 10), 0); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(emptyPage); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("storeId", storeId.toString())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(0))); + } + + @Test + @DisplayName("검증 실패 — 허용되지 않은 size(20) → 400") + void getReviews_invalid_page_size_returns_400() throws Exception { + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("size", "20")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).getReviews(any(), any(), any()); + } + + @Test + @DisplayName("검증 실패 — 허용되지 않은 sortBy 값 → 400") + void getReviews_invalid_sortBy_returns_400() throws Exception { + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("sortBy", "invalid")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).getReviews(any(), any(), any()); + } + + @Test + @DisplayName("검증 실패 — 허용되지 않은 sortDir 값 → 400") + void getReviews_invalid_sortDir_returns_400() throws Exception { + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("sortDir", "invalid")) + .andExpect(status().isBadRequest()); + verify(reviewService, never()).getReviews(any(), any(), any()); + } + + @Test + @DisplayName("성공 — size=30 허용값으로 조회") + void getReviews_valid_size_30() throws Exception { + Page emptyPage = new PageImpl<>( + Collections.emptyList(), PageRequest.of(0, 30), 0); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(emptyPage); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("size", "30")) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("성공 — sortBy=rating, sortDir=asc 정렬 조회") + void getReviews_sort_by_rating_asc() throws Exception { + ReviewResponse r1 = ReviewResponse.builder().userId(CUSTOMER_ID).rating(2).build(); + ReviewResponse r2 = ReviewResponse.builder().userId(CUSTOMER_ID).rating(5).build(); + Page page = new PageImpl<>( + List.of(r1, r2), PageRequest.of(0, 10), 2); + + when(reviewService.getReviews(eq(CUSTOMER_ID), eq(CUSTOMER_ROLE), any())) + .thenReturn(page); + + mockMvc.perform(get("/v1/reviews") + .with(withCustomerAuth) + .param("sortBy", "rating") + .param("sortDir", "asc")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content", hasSize(2))); + } } + + // ───────────────────────────────────────────────────────────────── // DELETE /v1/reviews/{reviewId} - @Test - void deleteReview_success() throws Exception { - UUID reviewId = UUID.randomUUID(); - doNothing().when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); - mockMvc.perform(delete("/v1/reviews/" + reviewId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.message").value("리뷰 삭제 성공")); - verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); - } - @Test - void deleteReview_fail_not_owner_propagates_403() throws Exception { - UUID reviewId = UUID.randomUUID(); - doThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)) - .when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); - mockMvc.perform(delete("/v1/reviews/" + reviewId)) - .andExpect(status().isForbidden()); - verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); - } - @Test - void deleteReview_fail_not_found_propagates_404() throws Exception { - UUID reviewId = UUID.randomUUID(); - doThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)) - .when(reviewService).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); - mockMvc.perform(delete("/v1/reviews/" + reviewId)) - .andExpect(status().isNotFound()); - verify(reviewService, times(1)).deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + // ───────────────────────────────────────────────────────────────── + @Nested + @DisplayName("DELETE /v1/reviews/{reviewId} — 리뷰 삭제") + class DeleteReview { + + @Test + @DisplayName("성공 — 본인 리뷰 삭제 시 200") + void deleteReview_success() throws Exception { + UUID reviewId = UUID.randomUUID(); + doNothing().when(reviewService) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + + mockMvc.perform(delete("/v1/reviews/" + reviewId) + .with(withCustomerAuth)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.message").value("리뷰 삭제 성공")); + + verify(reviewService, times(1)) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + } + + @Test + @DisplayName("실패 전파 — 권한 없음 → 403") + void deleteReview_fail_not_owner_propagates_403() throws Exception { + UUID reviewId = UUID.randomUUID(); + doThrow(new CustomException(ErrorCode.REVIEW_FORBIDDEN)) + .when(reviewService) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + + mockMvc.perform(delete("/v1/reviews/" + reviewId) + .with(withCustomerAuth)) + .andExpect(status().isForbidden()); + + verify(reviewService, times(1)) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + } + + @Test + @DisplayName("실패 전파 — REVIEW_NOT_FOUND → 404") + void deleteReview_fail_not_found_propagates_404() throws Exception { + UUID reviewId = UUID.randomUUID(); + doThrow(new CustomException(ErrorCode.REVIEW_NOT_FOUND)) + .when(reviewService) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + + mockMvc.perform(delete("/v1/reviews/" + reviewId) + .with(withCustomerAuth)) + .andExpect(status().isNotFound()); + + verify(reviewService, times(1)) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + } + + @Test + @DisplayName("실패 전파 — 예상치 못한 예외 → 500") + void deleteReview_fail_unexpected_error_returns_500() throws Exception { + UUID reviewId = UUID.randomUUID(); + doThrow(new RuntimeException("unexpected")) + .when(reviewService) + .deleteReview(eq(reviewId), eq(CUSTOMER_ID), eq(CUSTOMER_ROLE)); + + mockMvc.perform(delete("/v1/reviews/" + reviewId) + .with(withCustomerAuth)) + .andExpect(status().isInternalServerError()); + } } -} \ No newline at end of file +} + From 6ad58d96d19e7c6ef24ccda094d97d7cfdc48e0a Mon Sep 17 00:00:00 2001 From: jihoon Date: Tue, 10 Mar 2026 11:28:21 +0900 Subject: [PATCH 09/15] refactor: (Cart) apply CommonResponse + swagger docs --- .../cart/controller/CartController.java | 108 +++++++++++++++--- .../domain/cart/CartControllerTest.java | 40 +++---- 2 files changed, 114 insertions(+), 34 deletions(-) diff --git a/src/main/java/com/babjo/deliverycommerce/domain/cart/controller/CartController.java b/src/main/java/com/babjo/deliverycommerce/domain/cart/controller/CartController.java index 5522315..d6e653b 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/cart/controller/CartController.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/cart/controller/CartController.java @@ -5,9 +5,17 @@ import com.babjo.deliverycommerce.domain.cart.dto.CartItemQuantityUpdateRequestDto; import com.babjo.deliverycommerce.domain.cart.dto.CartResponseDto; import com.babjo.deliverycommerce.domain.cart.service.CartService; +import com.babjo.deliverycommerce.global.common.dto.CommonResponse; import com.babjo.deliverycommerce.global.security.CurrentUserResolver; +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 jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.units.qual.A; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; @@ -28,8 +36,20 @@ public CartController(CartService cartService, CurrentUserResolver currentUserRe this.currentUserResolver = currentUserResolver; } + @Operation( + summary = "내 장바구니 조회", + description = """ + - 로그인 사용자의 장바구니를 조회 합니다. + - 장바구니가 없으면 empty 응답을 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping - public CartResponseDto getMyCart(Authentication authentication) { + public ResponseEntity> getMyCart(Authentication authentication) { Long userId = currentUserResolver.getUserId(authentication); log.info("내 장바구니 조회 API 요청: userId={}", userId); @@ -38,13 +58,29 @@ public CartResponseDto getMyCart(Authentication authentication) { log.info("내 장바구니 조회 API 완료: userId={}, cartId={}, itemCount={}", userId, response.getCartId(), response.getItems().size()); - return response; + return CommonResponse.ok("내 장바구니 조회 성공", response); } + @Operation( + summary = "장바구니 상품 수량 변경", + description = """ + - 특정 장바구니 항목(cartItemId)의 수량을 변경합니다. + - quantity는 1 이상이어야 합니다. + - 본인 소유 장바구니 항목인지 검증합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 검증 실패(수량 1 미만 등)"), + @ApiResponse(responseCode = "403", description = "권한 없음(본인 장바구니 항목 아님)"), + @ApiResponse(responseCode = "404", description = "장바구니 항목이 존재하지 않음"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PatchMapping("/items/{cartItemId}") - public CartResponseDto updateQuantity(@PathVariable UUID cartItemId, - @Valid @RequestBody CartItemQuantityUpdateRequestDto request, - Authentication authentication) { + public ResponseEntity> updateQuantity(@PathVariable UUID cartItemId, + @Valid @RequestBody CartItemQuantityUpdateRequestDto request, + Authentication authentication) { Long userId = currentUserResolver.getUserId(authentication); @@ -54,12 +90,26 @@ public CartResponseDto updateQuantity(@PathVariable UUID cartItemId, log.info("장바구니 수량 수정 API 완료: userId={}, cartId={}, itemCount={}", userId, response.getCartId(), response.getItems().size()); - return response; + return CommonResponse.ok("장바구니 수량 수정 성공",response); } + @Operation( + summary = "장바구니 상품 삭제", + description = """ + - 특정 장바구니 항목을 삭제합니다.(Soft Delete) + - 본인 소유 장바구니 항목이 아니면 403 반환 + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "403", description = "권한 없음(본인 장바구니 항목 아님)"), + @ApiResponse(responseCode = "404", description = "장바구니 항목이 존재하지 않음"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @DeleteMapping("/items/{cartItemId}") - public ResponseEntity deleteItem(@PathVariable UUID cartItemId, - Authentication authentication) { + public ResponseEntity> deleteItem(@PathVariable UUID cartItemId, + Authentication authentication) { Long userId = currentUserResolver.getUserId(authentication); @@ -69,11 +119,23 @@ public ResponseEntity deleteItem(@PathVariable UUID cartItemId, log.info("장바구니 항목 삭제 API 완료: userId={}, cartItemId={}", userId, cartItemId); - return ResponseEntity.noContent().build(); + return CommonResponse.ok("장바구니 항목 삭제 성공", null); } + @Operation( + summary = "장바구니 비우기", + description = """ + - 로그인 사용자의 장바구니 항목을 전체 삭제합니다.(Soft Delete) + - 장바구니가 비어있어도 성공 처리합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "비우기 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @DeleteMapping - public ResponseEntity clearCart(Authentication authentication) { + public ResponseEntity> clearCart(Authentication authentication) { Long userId = currentUserResolver.getUserId(authentication); log.info("장바구니 비우기 API 요청: userId={}", userId); @@ -82,12 +144,30 @@ public ResponseEntity clearCart(Authentication authentication) { log.info("장바구니 비우기 API 완료: userId={}", userId); - return ResponseEntity.noContent().build(); + return CommonResponse.ok("장바구니 비우기 성공", null); } + @Operation( + summary = "장바구니 상품 추가", + description = """ + - 로그인 사용자의 장바구니에 상품을 추가합니다. + - 동일 상품이 이미 담겨 있으면 수량을 증가시킵니다. + - quantity가 null이면 기본값 1로 처리합니다. + - 상품이 존재하지 않거나 삭제된 상품이면 404를 반환합니다. + - 단일 가게 정책 위반 시 CART_STORE_MISMATCH 예외를 반환합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "추가 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 검증 실패(수량 1 미만 등)"), + @ApiResponse(responseCode = "404", description = "상품이 존재하지 않음/삭제됨"), + @ApiResponse(responseCode = "409", description = "단일 가게 정책 위반(CART_STORE_MISMATCH)"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PostMapping("/items") - public CartResponseDto addItem(@Valid @RequestBody CartItemAddRequestDto request, - Authentication authentication) { + public ResponseEntity> addItem(@Valid @RequestBody CartItemAddRequestDto request, + Authentication authentication) { Long userId = currentUserResolver.getUserId(authentication); @@ -97,7 +177,7 @@ public CartResponseDto addItem(@Valid @RequestBody CartItemAddRequestDto request log.info("장바구니 상품 추가 API 완료: userId={}, cartId={}, itemCount={}", userId, response.getCartId(), response.getItems().size()); - return response; + return CommonResponse.ok("장바구니 상품 추가 성공", response); } } diff --git a/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java index 191b929..db2cb17 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java @@ -62,12 +62,12 @@ public class CartControllerTest { //when & then mockMvc.perform(get("/v1/cart")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.cartId").value(cartId.toString())) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.items.length()").value(1)) - .andExpect(jsonPath("$.items[0].cartItemId").value(cartItemId.toString())) - .andExpect(jsonPath("$.items[0].productId").value(productId.toString())) - .andExpect(jsonPath("$.items[0].quantity").value(2)); + .andExpect(jsonPath("$.data.cartId").value(cartId.toString())) + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].cartItemId").value(cartItemId.toString())) + .andExpect(jsonPath("$.data.items[0].productId").value(productId.toString())) + .andExpect(jsonPath("$.data.items[0].quantity").value(2)); } @Test @@ -83,9 +83,9 @@ public class CartControllerTest { //when & then mockMvc.perform(get("/v1/cart")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.cartId").doesNotExist()) - .andExpect(jsonPath("$.storeId").doesNotExist()) - .andExpect(jsonPath("$.items.length()").value(0)); + .andExpect(jsonPath("$.data.cartId").doesNotExist()) + .andExpect(jsonPath("$.data.storeId").doesNotExist()) + .andExpect(jsonPath("$.data.items.length()").value(0)); } @Test @@ -116,11 +116,11 @@ public class CartControllerTest { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.cartId").value(cartId.toString())) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.items.length()").value(1)) - .andExpect(jsonPath("$.items[0].productId").value(productId.toString())) - .andExpect(jsonPath("$.items[0].quantity").value(3)); + .andExpect(jsonPath("$.data.cartId").value(cartId.toString())) + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].productId").value(productId.toString())) + .andExpect(jsonPath("$.data.items[0].quantity").value(3)); then(cartService).should() .updateQuantity(eq(1L), eq(cartItemId), any(CartItemQuantityUpdateRequestDto.class)); @@ -218,12 +218,12 @@ public class CartControllerTest { .contentType(MediaType.APPLICATION_JSON) .content(requestBody)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.cartId").value(cartId.toString())) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.items.length()").value(1)) - .andExpect(jsonPath("$.items[0].cartItemId").value(cartItemId.toString())) - .andExpect(jsonPath("$.items[0].productId").value(productId.toString())) - .andExpect(jsonPath("$.items[0].quantity").value(2)); + .andExpect(jsonPath("$.data.cartId").value(cartId.toString())) + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].cartItemId").value(cartItemId.toString())) + .andExpect(jsonPath("$.data.items[0].productId").value(productId.toString())) + .andExpect(jsonPath("$.data.items[0].quantity").value(2)); // then: 컨트롤러가 서비스 메서드를 실제로 호출했는지 확인(선택이지만 강력 추천) then(cartService).should().addItem(eq(1L), any(CartItemAddRequestDto.class)); From 5c8d5cf26d6cf476da65a6709a46c45d9d9eb250 Mon Sep 17 00:00:00 2001 From: jihoon Date: Tue, 10 Mar 2026 11:59:33 +0900 Subject: [PATCH 10/15] refactor: (Store) apply CommonResponse + swagger docs --- .../store/controller/StoreController.java | 91 +++++++++++++++++-- .../domain/store/StoreControllerTest.java | 62 +++++++------ 2 files changed, 116 insertions(+), 37 deletions(-) diff --git a/src/main/java/com/babjo/deliverycommerce/domain/store/controller/StoreController.java b/src/main/java/com/babjo/deliverycommerce/domain/store/controller/StoreController.java index 229c23c..41fcefb 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/store/controller/StoreController.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/store/controller/StoreController.java @@ -6,10 +6,15 @@ import com.babjo.deliverycommerce.domain.store.dto.StoreResponseDto; import com.babjo.deliverycommerce.domain.store.dto.StoreUpdateRequestDto; import com.babjo.deliverycommerce.domain.store.service.StoreService; +import com.babjo.deliverycommerce.global.common.dto.CommonResponse; import com.babjo.deliverycommerce.global.security.CurrentUserResolver; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; @@ -39,11 +44,25 @@ public StoreController(StoreService storeService, CurrentUserResolver currentUse * - ownerId는 인증 객체에서 추출한 현재 사용자 ID * - 요청 본문은 유효성 검사 */ + @Operation( + summary = "가게 생성", + description = """ + - OWNER 권한 사용자만 가게를 생성할 수 있습니다. + - ownerId는 인증 객체(Authentication)에서 추출합니다. + - 요청 본문은 유효성 검사를 수행합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "생성 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(OWNER 아님)") + }) @PostMapping @PreAuthorize("hasRole('OWNER')") @ResponseStatus(HttpStatus.CREATED) - public StoreResponseDto create(Authentication authentication, - @Valid @RequestBody StoreCreateRequestDto request) { + public ResponseEntity> create(Authentication authentication, + @Valid @RequestBody StoreCreateRequestDto request) { // 인증 객체에서 현재 로그인 사용자 ID 추출 // (헤더로 직접 userId를 받지 않고, 공용 인증 구조를 그대로 사용하기 위함) @@ -55,20 +74,30 @@ public StoreResponseDto create(Authentication authentication, StoreResponseDto result = storeService.create(ownerId, request); log.info("가게 생성 API 완료: storeId={}, ownerId={}", result.getStoreId(), ownerId); - return result; + return CommonResponse.created("가게 생성 성공", result); } /** * 가게 단건 조회 */ + @Operation( + summary = "가게 단건 조회", + description = """ + - storeId로 가게 정보를 조회합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 가게") + }) @GetMapping("/{storeId}") - public StoreResponseDto get(@PathVariable UUID storeId) { + public ResponseEntity> get(@PathVariable UUID storeId) { log.info("가게 단건 조회 요청: storeId={}", storeId); StoreResponseDto result = storeService.get(storeId); log.info("가게 단건 조회 완료: storeId={}", storeId); - return result; + return CommonResponse.ok("가게 단건 조회 성공", result); } /** @@ -77,9 +106,24 @@ public StoreResponseDto get(@PathVariable UUID storeId) { * - actorUserId는 인증 객체에서 추출한 현재 사용자 ID * - 요청 본문은 유효성 검사 */ + @Operation( + summary = "가게 수정", + description = """ + - OWNER 권한 사용자만 가게를 수정할 수 있습니다. + - actorUserId는 인증 객체(Authentication)에서 추출합니다. + - 요청 본문은 유효성 검사를 수행합니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "수정 성공"), + @ApiResponse(responseCode = "400", description = "요청 값 검증 실패"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(OWNER 아님)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 가게") + }) @PatchMapping("/{storeId}") @PreAuthorize("hasRole('OWNER')") - public StoreResponseDto update(@PathVariable UUID storeId, + public ResponseEntity> update(@PathVariable UUID storeId, Authentication authentication, @Valid @RequestBody StoreUpdateRequestDto request) { @@ -91,7 +135,7 @@ public StoreResponseDto update(@PathVariable UUID storeId, StoreResponseDto result = storeService.update(storeId, actorUserId, request); log.info("가게 수정 완료: storeId={}, actorUserId={}", storeId, actorUserId); - return result; + return CommonResponse.ok("가게 수정 성공", result); } /** @@ -99,10 +143,24 @@ public StoreResponseDto update(@PathVariable UUID storeId, * - OWNER 권한 사용자만 가능 * - actorUserId는 인증 객체에서 추출 */ + @Operation( + summary = "가게 삭제", + description = """ + - OWNER 권한 사용자만 가게를 삭제할 수 있습니다. + - actorUserId는 인증 객체(Authentication)에서 추출합니다. + - 삭제는 Soft Delete 정책을 따릅니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "403", description = "권한 없음(OWNER 아님)"), + @ApiResponse(responseCode = "404", description = "존재하지 않는 가게") + }) @DeleteMapping("/{storeId}") @PreAuthorize("hasRole('OWNER')") @ResponseStatus(HttpStatus.NO_CONTENT) - public void delete(@PathVariable UUID storeId, + public ResponseEntity> delete(@PathVariable UUID storeId, Authentication authentication) { Long actorUserId = currentUserResolver.getUserId(authentication); @@ -111,6 +169,8 @@ public void delete(@PathVariable UUID storeId, storeService.delete(storeId, actorUserId); log.info("가게 삭제 완료: storeId={}, actorUserId={}", storeId, actorUserId); + + return CommonResponse.ok("가게 삭제 성공", null); } /** @@ -118,8 +178,19 @@ public void delete(@PathVariable UUID storeId, * - category, name은 선택 조건 * - page 기본값은 0 */ + @Operation( + summary = "가게 목록 조회", + description = """ + - 가게 목록을 조회합니다. + - category, name은 선택 조건입니다. + - page 기본값은 0입니다. + """ + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "조회 성공") + }) @GetMapping - public List getStores(@RequestParam(required = false) String category, + public ResponseEntity>> getStores(@RequestParam(required = false) String category, @RequestParam(required = false) String name, @RequestParam(defaultValue = "0") int page) { @@ -129,6 +200,6 @@ public List getStores(@RequestParam(required = false) Stri log.info("가게 목록 조회 완료 resultCount={}", response.size()); - return response; + return CommonResponse.ok("가게 목록 조회 성공", response); } } diff --git a/src/test/java/com/babjo/deliverycommerce/domain/store/StoreControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/store/StoreControllerTest.java index b978735..0c210b7 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/store/StoreControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/store/StoreControllerTest.java @@ -22,9 +22,11 @@ import java.util.List; import java.util.UUID; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; @@ -81,11 +83,11 @@ void getStores_success() throws Exception { // when & then mockMvc.perform(get("/v1/stores")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].name").value("한식당")) - .andExpect(jsonPath("$[0].category").value("한식")) - .andExpect(jsonPath("$[1].name").value("치킨집")) - .andExpect(jsonPath("$[1].category").value("치킨")); + .andExpect(jsonPath("$.data.length()").value(2)) + .andExpect(jsonPath("$.data[0].name").value("한식당")) + .andExpect(jsonPath("$.data[0].category").value("한식")) + .andExpect(jsonPath("$.data[1].name").value("치킨집")) + .andExpect(jsonPath("$.data[1].category").value("치킨")); } @Test @@ -108,16 +110,19 @@ void getStore_success() throws Exception { given(storeService.get(storeId)) .willReturn(response); + + given(currentUserResolver.getUserId(any(Authentication.class))).willReturn(1L); + // when & then mockMvc.perform(get("/v1/stores/{storeId}", storeId)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.ownerId").value(1L)) - .andExpect(jsonPath("$.category").value("한식")) - .andExpect(jsonPath("$.name").value("한식당")) - .andExpect(jsonPath("$.address").value("서울시 강남구")) - .andExpect(jsonPath("$.averageRating").value(4.5)) - .andExpect(jsonPath("$.reviewCount").value(120)); + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.ownerId").value(1L)) + .andExpect(jsonPath("$.data.category").value("한식")) + .andExpect(jsonPath("$.data.name").value("한식당")) + .andExpect(jsonPath("$.data.address").value("서울시 강남구")) + .andExpect(jsonPath("$.data.averageRating").value(4.5)) + .andExpect(jsonPath("$.data.reviewCount").value(120)); } @Test @@ -158,13 +163,13 @@ void createStore_success() throws Exception { .contentType("application/json") .content(requestBody)) .andExpect(status().isCreated()) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.ownerId").value(1L)) - .andExpect(jsonPath("$.category").value("한식")) - .andExpect(jsonPath("$.name").value("한식당")) - .andExpect(jsonPath("$.address").value("서울시 강남구")) - .andExpect(jsonPath("$.averageRating").value(0.0)) - .andExpect(jsonPath("$.reviewCount").value(0)); + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.ownerId").value(1L)) + .andExpect(jsonPath("$.data.category").value("한식")) + .andExpect(jsonPath("$.data.name").value("한식당")) + .andExpect(jsonPath("$.data.address").value("서울시 강남구")) + .andExpect(jsonPath("$.data.averageRating").value(0.0)) + .andExpect(jsonPath("$.data.reviewCount").value(0)); } @Test @@ -226,13 +231,13 @@ void updateStore_success() throws Exception { .contentType("application/json") .content(requestBody)) .andExpect(status().isOk()) - .andExpect(jsonPath("$.storeId").value(storeId.toString())) - .andExpect(jsonPath("$.ownerId").value(1L)) - .andExpect(jsonPath("$.category").value("치킨")) - .andExpect(jsonPath("$.name").value("치킨집")) - .andExpect(jsonPath("$.address").value("서울시 서초구")) - .andExpect(jsonPath("$.averageRating").value(4.3)) - .andExpect(jsonPath("$.reviewCount").value(10)); + .andExpect(jsonPath("$.data.storeId").value(storeId.toString())) + .andExpect(jsonPath("$.data.ownerId").value(1L)) + .andExpect(jsonPath("$.data.category").value("치킨")) + .andExpect(jsonPath("$.data.name").value("치킨집")) + .andExpect(jsonPath("$.data.address").value("서울시 서초구")) + .andExpect(jsonPath("$.data.averageRating").value(4.3)) + .andExpect(jsonPath("$.data.reviewCount").value(10)); } @Test @@ -272,7 +277,10 @@ void deleteStore_success() throws Exception { // when & then mockMvc.perform(delete("/v1/stores/{storeId}", storeId) .with(csrf())) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(nullValue())); + + then(storeService).should().delete(eq(storeId), eq(1L)); } @Test From d00d17084426404fc80b24e627dd6770ac8de9ec Mon Sep 17 00:00:00 2001 From: jihoon Date: Tue, 10 Mar 2026 12:15:51 +0900 Subject: [PATCH 11/15] =?UTF-8?q?test:=20(cart)=20DELETE=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../deliverycommerce/domain/cart/CartControllerTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java index db2cb17..21d4c1a 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/cart/CartControllerTest.java @@ -21,6 +21,7 @@ import java.util.List; import java.util.UUID; +import static org.hamcrest.Matchers.nullValue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @@ -162,7 +163,8 @@ public class CartControllerTest { //when & then mockMvc.perform(delete("/v1/cart/items/{cartItemId}", cartItemId) .with(csrf())) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(nullValue())); //서비스 호출 검증 then(cartService).should().deleteItem(eq(1L), eq(cartItemId)); @@ -178,7 +180,8 @@ public class CartControllerTest { //when & then mockMvc.perform(delete("/v1/cart") .with(csrf())) - .andExpect(status().isNoContent()); + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").value(nullValue())); //then then(cartService).should().clearCart(eq(1L)); From ec8b4b7e85f1fbc05c9d424883ec037f14ac987c Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 12:18:27 +0900 Subject: [PATCH 12/15] feat: add unit tests for payment cancellation and deletion scenarios --- .../global/exception/ErrorCode.java | 3 +- .../payment/service/PaymentServiceTest.java | 39 ++++++++++++++++++- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java index fb20edf..ddc0bda 100644 --- a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java +++ b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java @@ -79,7 +79,6 @@ public enum ErrorCode { REVIEW_NOT_FOUND(HttpStatus.NOT_FOUND, "REVIEW_NOT_FOUND", "존재하지 않는 리뷰입니다."), REVIEW_ALREADY_EXISTS(HttpStatus.CONFLICT, "REVIEW_ALREADY_EXISTS", "이미 리뷰를 작성했습니다."), REVIEW_FORBIDDEN(HttpStatus.FORBIDDEN, "REVIEW_FORBIDDEN", "해당 리뷰에 대한 권한이 없습니다."), - REVIEW_FORBIDDEN(HttpStatus.FORBIDDEN, "REVIEW_FORBIDDEN", "해당 리뷰에 대한 권한이 없습니다."), // ── Payment ─────────────────────────────────────── PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT_NOT_FOUND", "존재하지 않는 결제입니다."), @@ -87,7 +86,7 @@ public enum ErrorCode { PAYMENT_INVALID_STATUS(HttpStatus.BAD_REQUEST, "PAYMENT_INVALID_STATUS", "현재 결제 상태에서는 해당 작업을 수행할 수 없습니다."), PAYMENT_ALREADY_CANCELED(HttpStatus.BAD_REQUEST, "PAYMENT_ALREADY_CANCELED", "이미 취소된 결제입니다."), PAYMENT_CANCEL_TIME_EXPIRED(HttpStatus.BAD_REQUEST, "PAYMENT_CANCEL_TIME_EXPIRED", "결제 취소 가능 시간(5분)이 초과되었습니다."), - PAYMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "PAYMENT_FORBIDDEN", "해당 결제에 대한 권한이 없습니다."); + PAYMENT_FORBIDDEN(HttpStatus.FORBIDDEN, "PAYMENT_FORBIDDEN", "해당 결제에 대한 권한이 없습니다."), // ── AiLog ────────────────────────────────────────── AI_GENERATION_FAILED(HttpStatus.SERVICE_UNAVAILABLE, "AI_GENERATION_FAILED", "AI 설명 생성에 실패했습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java index 34b5fe3..456f69d 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/payment/service/PaymentServiceTest.java @@ -23,6 +23,7 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; import java.util.UUID; @@ -223,8 +224,14 @@ class CancelPayment { @Test @DisplayName("정상 케이스 - READY → CANCELED (5분 이내)") - void cancelPayment_success() { + void cancelPayment_success() throws Exception { // given + // BaseEntity.createdAt 은 JPA Auditing 에 의해 세팅되므로 + // 단위 테스트에서는 리플렉션으로 현재 시각으로 직접 세팅 + var createdAtField = payment.getClass().getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(payment, LocalDateTime.now()); + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); given(paymentHistoryRepository.save(any(PaymentHistory.class))).willReturn(mockHistory); @@ -235,6 +242,23 @@ void cancelPayment_success() { assertThat(response.getStatus()).isEqualTo(PaymentStatus.CANCELED); } + @Test + @DisplayName("실패 케이스 - 생성 후 5분 초과 시 취소 불가 예외") + void cancelPayment_fail_timeExpired() throws Exception { + // given + // createdAt 을 6분 전으로 세팅하여 5분 초과 상황 재현 + var createdAtField = payment.getClass().getSuperclass().getDeclaredField("createdAt"); + createdAtField.setAccessible(true); + createdAtField.set(payment, LocalDateTime.now().minusMinutes(6)); + + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.cancelPayment(paymentId, null, userId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_CANCEL_TIME_EXPIRED.getMessage()); + } + @Test @DisplayName("실패 케이스 - 이미 CANCELED 상태에서 취소 시 예외") void cancelPayment_fail_alreadyCanceled() { @@ -350,6 +374,19 @@ void deletePayment_fail_alreadyDeleted() { .isInstanceOf(CustomException.class) .hasMessageContaining(ErrorCode.ALREADY_DELETED.getMessage()); } + + @Test + @DisplayName("실패 케이스 - 타인 결제 삭제 시도 시 예외 (비관리자)") + void deletePayment_fail_forbidden() { + // given + Long otherUserId = 999L; + given(paymentRepository.findById(paymentId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.deletePayment(paymentId, otherUserId, false)) + .isInstanceOf(CustomException.class) + .hasMessageContaining(ErrorCode.PAYMENT_FORBIDDEN.getMessage()); + } } } From 6da01269569aae328048e05d27ce617142321906 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 14:04:15 +0900 Subject: [PATCH 13/15] feat: add QueryDslConfig import to UserRepositoryTest for custom repository support --- .../deliverycommerce/domain/user/UserRepositoryTest.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/test/java/com/babjo/deliverycommerce/domain/user/UserRepositoryTest.java b/src/test/java/com/babjo/deliverycommerce/domain/user/UserRepositoryTest.java index dfc7ce0..416f355 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/user/UserRepositoryTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/user/UserRepositoryTest.java @@ -5,6 +5,7 @@ import com.babjo.deliverycommerce.global.common.audit.AuditorAwareImpl; import com.babjo.deliverycommerce.global.common.enums.UserEnumRole; import com.babjo.deliverycommerce.global.jpa.JpaAuditingConfig; +import com.babjo.deliverycommerce.global.jpa.QueryDslConfig; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -18,7 +19,8 @@ @DataJpaTest @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // createdAt(nullable=false)조건 필요해서 추가 -@Import({JpaAuditingConfig.class, AuditorAwareImpl.class}) +// PaymentRepositoryImpl 등 JPAQueryFactory를 사용하는 커스텀 Repository 구현체를 위해 QueryDslConfig 추가 +@Import({JpaAuditingConfig.class, AuditorAwareImpl.class, QueryDslConfig.class}) public class UserRepositoryTest { @Autowired From 864293a45c9084566a801ad0d78942c35af4af2d Mon Sep 17 00:00:00 2001 From: kim-jinyeong Date: Tue, 10 Mar 2026 14:11:54 +0900 Subject: [PATCH 14/15] =?UTF-8?q?feat=20:=20=EA=B0=80=EA=B2=8C=20=EC=86=8C?= =?UTF-8?q?=EC=9C=A0=EA=B6=8C=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/order/service/OrderService.java | 19 +++++++- .../domain/order/OrderServiceTest.java | 46 ++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/main/java/com/babjo/deliverycommerce/domain/order/service/OrderService.java b/src/main/java/com/babjo/deliverycommerce/domain/order/service/OrderService.java index aef6939..0902bd4 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/order/service/OrderService.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/order/service/OrderService.java @@ -8,6 +8,8 @@ import com.babjo.deliverycommerce.domain.order.repository.OrderRepository; import com.babjo.deliverycommerce.domain.product.entity.Product; import com.babjo.deliverycommerce.domain.product.repository.ProductRepository; +import com.babjo.deliverycommerce.domain.store.entity.Store; +import com.babjo.deliverycommerce.domain.store.repository.StoreRepository; import com.babjo.deliverycommerce.global.exception.CustomException; import com.babjo.deliverycommerce.global.exception.ErrorCode; import lombok.RequiredArgsConstructor; @@ -27,6 +29,8 @@ public class OrderService { private final OrderRepository orderRepository; private final ProductRepository productRepository; + private final StoreRepository storeRepository; + // 주문 생성 @Transactional public OrderResponseDto.OrderDetail createOrder(Long userId, OrderRequestDto.CreateOrder request){ @@ -108,6 +112,7 @@ public OrderResponseDto.OrderAction softDeleteOrder(UUID orderId, Long userId){ @Transactional public OrderResponseDto.OrderAction acceptOrder(UUID orderId, Long ownerId, OrderRequestDto.AcceptOrder request){ Order order = findActiveOrder(orderId); + validateStoreOwner(order.getStoreId(),ownerId); order.accept(ownerId, request.getCookingMinutes()); return OrderResponseDto.OrderAction.from(order, order.getAcceptedAt()); @@ -117,20 +122,30 @@ public OrderResponseDto.OrderAction acceptOrder(UUID orderId, Long ownerId, Orde @Transactional public OrderResponseDto.OrderAction updateOrderStatus(UUID orderId,Long ownerId, OrderStatus status){ Order order = findActiveOrder(orderId); + validateStoreOwner(order.getStoreId(),ownerId); switch (status){ case PREPARING -> order.startPreparing(ownerId); case PICKUP_READY -> order.readyPickup(ownerId); case PICKED_UP -> order.completePickup(ownerId); default -> throw new CustomException(ErrorCode.INVALID_ORDER_STATUS); } - return OrderResponseDto.OrderAction.from(order, LocalDateTime.now()); + return OrderResponseDto.OrderAction.from(order, order.getUpdatedAt()); } - // orderId 조회 로직 공통화 + // orderId 조회 로직 공통 메소드 private Order findActiveOrder(UUID orderId){ return orderRepository.findById(orderId) .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); } + // 가게 소유권 검증 로직 공통 메소드 + private void validateStoreOwner(UUID storeId, Long ownerId){ + Store store = storeRepository.findByStoreIdAndDeletedAtIsNull(storeId) + .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); + if(!store.getOwnerId().equals(ownerId)){ + throw new CustomException(ErrorCode.STORE_FORBIDDEN); + } + } + } diff --git a/src/test/java/com/babjo/deliverycommerce/domain/order/OrderServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/order/OrderServiceTest.java index 908a18c..6c7c340 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/order/OrderServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/order/OrderServiceTest.java @@ -9,6 +9,8 @@ import com.babjo.deliverycommerce.domain.order.service.OrderService; import com.babjo.deliverycommerce.domain.product.entity.Product; import com.babjo.deliverycommerce.domain.product.repository.ProductRepository; +import com.babjo.deliverycommerce.domain.store.entity.Store; +import com.babjo.deliverycommerce.domain.store.repository.StoreRepository; import com.babjo.deliverycommerce.global.exception.CustomException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -39,6 +41,9 @@ public class OrderServiceTest { @Mock private ProductRepository productRepository; + @Mock + private StoreRepository storeRepository; + @Test @DisplayName("주문 생성 성공 테스트") void createOrder_success() { @@ -142,10 +147,16 @@ void getOrderDetails_success() { @DisplayName("주문 접수 성공") void acceptOrder_success() { UUID orderId = UUID.randomUUID(); + UUID storeId = UUID.randomUUID(); Long ownerId = 100L; - Order order = spy(Order.createOrder(1L, UUID.randomUUID(), "주소", "메시지", List.of())); + Order order = spy(Order.createOrder(1L, storeId, "주소", "메시지", List.of())); given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + + Store mockStore = mock(Store.class); + given(mockStore.getOwnerId()).willReturn(ownerId); + given(storeRepository.findByStoreIdAndDeletedAtIsNull(storeId)).willReturn(Optional.of(mockStore)); + OrderRequestDto.AcceptOrder request = new OrderRequestDto.AcceptOrder(30); OrderResponseDto.OrderAction result = orderService.acceptOrder(orderId, ownerId, request); @@ -153,18 +164,49 @@ void acceptOrder_success() { assertThat(result.getStatus()).isEqualTo(OrderStatus.ACCEPTED); assertThat(order.getCookingMinutes()).isEqualTo(30); assertThat(order.getAcceptedBy()).isEqualTo(ownerId); + + verify(storeRepository).findByStoreIdAndDeletedAtIsNull(storeId); + } + + @Test + @DisplayName("주문 접수 실패 - 가게 소유주가 아닐 때") + void acceptOrder_fail_notStoreOwner() { + + UUID orderId = UUID.randomUUID(); + UUID storeId = UUID.randomUUID(); + Long realOwnerId = 100L; + Long fakeOwnerId = 999L; + + Order order = mock(Order.class); + given(order.getStoreId()).willReturn(storeId); + given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + + Store mockStore = mock(Store.class); + given(mockStore.getOwnerId()).willReturn(realOwnerId); + given(storeRepository.findByStoreIdAndDeletedAtIsNull(storeId)) + .willReturn(Optional.of(mockStore)); + + assertThatThrownBy(() -> orderService.acceptOrder(orderId, fakeOwnerId, new OrderRequestDto.AcceptOrder(30))) + .isInstanceOf(CustomException.class) + .hasMessageContaining("해당 가게에 대한 권한이 없습니다."); } @Test @DisplayName("주문 상태 변경 성공 - 조리 시작") void updateOrderStatus_preparing() { UUID orderId = UUID.randomUUID(); + UUID storeId = UUID.randomUUID(); Long ownerId = 100L; - Order order = spy(Order.createOrder(1L, UUID.randomUUID(), "주소", "메시지", List.of())); + + Order order = spy(Order.createOrder(1L, storeId, "주소", "메시지", List.of())); order.accept(ownerId, 30); // 접수된 상태여야 조리 시작 가능 given(orderRepository.findById(orderId)).willReturn(Optional.of(order)); + Store mockStore = mock(Store.class); + given(mockStore.getOwnerId()).willReturn(ownerId); + given(storeRepository.findByStoreIdAndDeletedAtIsNull(storeId)).willReturn(Optional.of(mockStore)); + orderService.updateOrderStatus(orderId, ownerId, OrderStatus.PREPARING); assertThat(order.getStatus()).isEqualTo(OrderStatus.PREPARING); From 7a23bdbaef277d211879ef4093cf97981851e4c7 Mon Sep 17 00:00:00 2001 From: githyj-jang Date: Tue, 10 Mar 2026 14:46:01 +0900 Subject: [PATCH 15/15] feat: integrate Order entity into Review and update related logic for review creation --- .../review/dto/ReviewCreateRequest.java | 2 +- .../domain/review/entity/Review.java | 11 +- .../domain/review/mapper/ReviewMapper.java | 11 +- .../review/repository/ReviewRepository.java | 1 + .../domain/review/service/ReviewService.java | 38 +- .../global/exception/ErrorCode.java | 3 +- .../controller/ReviewControllerTest.java | 57 ++- .../domain/review/entity/ReviewTest.java | 218 ----------- .../review/mapper/ReviewMapperTest.java | 35 +- .../review/service/ReviewServiceTest.java | 339 ++++++++++++++++-- 10 files changed, 415 insertions(+), 300 deletions(-) delete mode 100644 src/test/java/com/babjo/deliverycommerce/domain/review/entity/ReviewTest.java diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewCreateRequest.java b/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewCreateRequest.java index d062126..196e0a6 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewCreateRequest.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/dto/ReviewCreateRequest.java @@ -13,7 +13,7 @@ @Getter @NoArgsConstructor public class ReviewCreateRequest { - // [TODO] Order 도메인 연결 후 @NotNull 어노테이션 추가 필요 + @NotNull private UUID orderId; @NotNull diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/entity/Review.java b/src/main/java/com/babjo/deliverycommerce/domain/review/entity/Review.java index dd57c42..95b6436 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/entity/Review.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/entity/Review.java @@ -1,5 +1,6 @@ package com.babjo.deliverycommerce.domain.review.entity; +import com.babjo.deliverycommerce.domain.order.entity.Order; import com.babjo.deliverycommerce.domain.store.entity.Store; import com.babjo.deliverycommerce.domain.user.entity.User; import com.babjo.deliverycommerce.global.common.entity.BaseEntity; @@ -26,10 +27,9 @@ public class Review extends BaseEntity { @JoinColumn(name = "user_id", nullable = false) private User user; - // [TODO] Order 도메인 연결 후 주석 해제 -// @OneToOne(fetch = FetchType.LAZY) -// @JoinColumn(name = "order_id", nullable = false, unique = true) -// private Order order; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "order_id", nullable = false, unique = true) + private Order order; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "store_id", nullable = false) @@ -40,9 +40,10 @@ public class Review extends BaseEntity { @Column(columnDefinition = "TEXT") private String content; - public static Review create(User user, Store store, Integer rating, String content) { + public static Review create(User user, Order order, Store store, Integer rating, String content) { Review review = new Review(); review.user = user; + review.order = order; review.store = store; review.rating = rating; review.content = content; diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapper.java b/src/main/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapper.java index c4983c3..b958ad0 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapper.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapper.java @@ -1,5 +1,6 @@ package com.babjo.deliverycommerce.domain.review.mapper; +import com.babjo.deliverycommerce.domain.order.entity.Order; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; @@ -16,8 +17,7 @@ public ReviewResponse toResponse(Review review) { return ReviewResponse.builder() .reviewId(review.getReviewId()) .userId(review.getUser().getUserId()) - // [TODO] Order 연결 후 주석 해제 - // .orderId(review.getOrder().getOrderId()) + .orderId(review.getOrder().getOrderId()) .storeId(review.getStore().getStoreId()) .rating(review.getRating()) .content(review.getContent()) @@ -29,12 +29,12 @@ public ReviewResponse toResponse(Review review) { public Review toEntity( ReviewCreateRequest createRequest, User user, -// Order order, + Order order, Store store ) { return Review.create( user, -// order, + order, store, createRequest.getRating(), createRequest.getContent() @@ -45,8 +45,7 @@ public ReviewCreateResponse toCreateResponse(Review review) { return ReviewCreateResponse.builder() .reviewId(review.getReviewId()) .userId(review.getUser().getUserId()) - // [TODO] Order 연결 후 주석 해제 - // .orderId(review.getOrder().getOrderId()) + .orderId(review.getOrder().getOrderId()) .storeId(review.getStore().getStoreId()) .rating(review.getRating()) .content(review.getContent()) diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java b/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java index 56f6a0d..777f33b 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/repository/ReviewRepository.java @@ -45,4 +45,5 @@ public interface ReviewRepository extends JpaRepository { // [TODO] Order 도메인 연결 후 주석 해제 - 중복 리뷰 방지용 // boolean existsByOrder(Order order); + boolean existsByOrder_OrderId(UUID orderId); } diff --git a/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java b/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java index 33af49b..a1a34fd 100644 --- a/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java +++ b/src/main/java/com/babjo/deliverycommerce/domain/review/service/ReviewService.java @@ -1,5 +1,8 @@ package com.babjo.deliverycommerce.domain.review.service; +import com.babjo.deliverycommerce.domain.order.entity.Order; +import com.babjo.deliverycommerce.domain.order.entity.OrderStatus; +import com.babjo.deliverycommerce.domain.order.repository.OrderRepository; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; @@ -24,10 +27,6 @@ import java.util.List; import java.util.UUID; -// [TODO] Order 도메인 연결 후 import 추가 필요 -// import com.babjo.deliverycommerce.domain.order.entity.Order; -// import com.babjo.deliverycommerce.domain.order.entity.OrderStatus; -// import com.babjo.deliverycommerce.domain.order.repository.OrderRepository; /** * 리뷰 서비스 @@ -43,7 +42,7 @@ public class ReviewService { private final ReviewRepository reviewRepository; private final StoreRepository storeRepository; private final UserRepository userRepository; -// private final OrderRepository orderRepository; + private final OrderRepository orderRepository; private final ReviewMapper reviewMapper; // ───────────────────────────────────────────────────────────────── @@ -56,20 +55,29 @@ public ReviewCreateResponse createReview(Long userId, ReviewCreateRequest create User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); -// Order order = orderRepository.findById(createRequest.getOrderId()) -// .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); -// if (order.getStatus() != OrderStatus.COMPLETED) { -// throw new CustomException(ErrorCode.ORDER_NOT_COMPLETED); -// } -// boolean alreadyExists = reviewRepository.existsByOrder(order); -// if (alreadyExists) { -// throw new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); -// } + // 주문 조회 및 검증 + Order order = orderRepository.findById(createRequest.getOrderId()) + .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND)); + + // 본인 주문인지 확인 + if (!order.getUserId().equals(userId)) { + throw new CustomException(ErrorCode.NOT_ORDER_USER); + } + + // 픽업 완료 상태만 리뷰 작성 가능 + if (order.getStatus() != OrderStatus.PICKED_UP) { + throw new CustomException(ErrorCode.ORDER_NOT_COMPLETED); + } + + // 이미 리뷰가 존재하는 주문인지 확인 + if (reviewRepository.existsByOrder_OrderId(order.getOrderId())) { + throw new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); + } Store store = storeRepository.findByStoreIdAndDeletedAtIsNull(createRequest.getStoreId()) .orElseThrow(() -> new CustomException(ErrorCode.STORE_NOT_FOUND)); - Review review = reviewMapper.toEntity(createRequest, user, store); + Review review = reviewMapper.toEntity(createRequest, user, order, store); Review savedReview = reviewRepository.save(review); store.addReview(savedReview.getRating()); diff --git a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java index ddc0bda..6ef25ea 100644 --- a/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java +++ b/src/main/java/com/babjo/deliverycommerce/global/exception/ErrorCode.java @@ -100,7 +100,8 @@ public enum ErrorCode { INVALID_ORDER_STATUS(HttpStatus.BAD_REQUEST, "INVALID_ORDER_STATUS", "현재 주문 상태에서는 수행할 수 없는 작업입니다."), ORDER_ACCESS_DENIED(HttpStatus.FORBIDDEN, "ORDER_ACCESS_DENIED", "주문에 대한 접근 권한이 없습니다."), INVALID_COOKING_TIME(HttpStatus.BAD_REQUEST, "INVALID_COOKING_TIME", "올바른 조리 시간을 입력해주세요. ( 1분 이상 )"), - PRICE_MISMATCH(HttpStatus.BAD_REQUEST, "PRICE_MISMATCH", "상품 가격 정보가 일치하지 않습니다."); + PRICE_MISMATCH(HttpStatus.BAD_REQUEST, "PRICE_MISMATCH", "상품 가격 정보가 일치하지 않습니다."), + ORDER_NOT_COMPLETED(HttpStatus.BAD_REQUEST, "ORDER_NOT_COMPLETED", "픽업이 완료된 주문에만 리뷰를 작성할 수 있습니다."); private final HttpStatus status; private final String code; diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java index e398477..a04c33d 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/controller/ReviewControllerTest.java @@ -100,6 +100,7 @@ class CreateReview { void createReview_success() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); ReviewCreateResponse response = ReviewCreateResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) .rating(4).content("good").createdAt(LocalDateTime.now()).build(); @@ -110,7 +111,7 @@ void createReview_success() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", storeId))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", orderId, storeId))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.reviewId").value(reviewId.toString())) .andExpect(jsonPath("$.data.userId").value(CUSTOMER_ID.intValue())) @@ -125,6 +126,7 @@ void createReview_success() throws Exception { void createReview_boundary_rating_min_1() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); ReviewCreateResponse response = ReviewCreateResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) .rating(1).content("bad").createdAt(LocalDateTime.now()).build(); @@ -135,7 +137,7 @@ void createReview_boundary_rating_min_1() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":1,\"content\":\"bad\"}", storeId))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":1,\"content\":\"bad\"}", orderId, storeId))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.rating").value(1)); } @@ -145,6 +147,7 @@ void createReview_boundary_rating_min_1() throws Exception { void createReview_boundary_rating_max_5() throws Exception { UUID storeId = UUID.randomUUID(); UUID reviewId = UUID.randomUUID(); + UUID orderId = UUID.randomUUID(); ReviewCreateResponse response = ReviewCreateResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).storeId(storeId) .rating(5).content("great").createdAt(LocalDateTime.now()).build(); @@ -155,7 +158,7 @@ void createReview_boundary_rating_max_5() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":5,\"content\":\"great\"}", storeId))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":5,\"content\":\"great\"}", orderId, storeId))) .andExpect(status().isCreated()) .andExpect(jsonPath("$.data.rating").value(5)); } @@ -232,8 +235,8 @@ void createReview_fail_user_not_found_propagates_404() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", - UUID.randomUUID()))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) .andExpect(status().isNotFound()); verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); } @@ -248,8 +251,8 @@ void createReview_fail_store_not_found_propagates_404() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", - UUID.randomUUID()))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) .andExpect(status().isNotFound()); verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); } @@ -264,12 +267,44 @@ void createReview_fail_duplicate_review_propagates_409() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", - UUID.randomUUID()))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) .andExpect(status().isConflict()); verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); } + @Test + @DisplayName("실패 전파 — ORDER_NOT_FOUND → 404") + void createReview_fail_order_not_found_propagates_404() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.ORDER_NOT_FOUND)); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) + .andExpect(status().isNotFound()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + + @Test + @DisplayName("실패 전파 — ORDER_NOT_COMPLETED → 400") + void createReview_fail_order_not_completed_propagates_400() throws Exception { + when(reviewService.createReview(eq(CUSTOMER_ID), any())) + .thenThrow(new CustomException(ErrorCode.ORDER_NOT_COMPLETED)); + + mockMvc.perform(post("/v1/reviews") + .with(withCustomerAuth) + .contentType(MediaType.APPLICATION_JSON) + .content(String.format( + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) + .andExpect(status().isBadRequest()); + verify(reviewService, times(1)).createReview(eq(CUSTOMER_ID), any()); + } + @Test @DisplayName("실패 전파 — 예상치 못한 예외 → 500") void createReview_fail_unexpected_error_returns_500() throws Exception { @@ -280,8 +315,8 @@ void createReview_fail_unexpected_error_returns_500() throws Exception { .with(withCustomerAuth) .contentType(MediaType.APPLICATION_JSON) .content(String.format( - "{\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", - UUID.randomUUID()))) + "{\"orderId\":\"%s\",\"storeId\":\"%s\",\"rating\":4,\"content\":\"good\"}", + UUID.randomUUID(), UUID.randomUUID()))) .andExpect(status().isInternalServerError()); } } diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/entity/ReviewTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/entity/ReviewTest.java deleted file mode 100644 index 9cceb26..0000000 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/entity/ReviewTest.java +++ /dev/null @@ -1,218 +0,0 @@ -package com.babjo.deliverycommerce.domain.review.entity; - -import com.babjo.deliverycommerce.domain.store.entity.Store; -import com.babjo.deliverycommerce.global.common.enums.UserEnumRole; -import com.babjo.deliverycommerce.domain.user.entity.User; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import static org.assertj.core.api.Assertions.assertThat; - -class ReviewTest { - - private Store store; - private User user; - - @BeforeEach - void setUp() { - store = Store.create(1L, "한식", "테스트 식당", "서울시 강남구"); - user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); - } - - // ─────────────────────────────────────────────── - // create() 정적 팩토리 메서드 - // ─────────────────────────────────────────────── - - @Test - void create_정상_생성() { - // when - Review review = Review.create(user, store, 4, "맛있어요"); - - // then - assertThat(review.getUser()).isEqualTo(user); - assertThat(review.getStore()).isEqualTo(store); - assertThat(review.getRating()).isEqualTo(4); - assertThat(review.getContent()).isEqualTo("맛있어요"); - } - - @Test - void create_rating_최솟값_1() { - // when - Review review = Review.create(user, store, 1, "별로에요"); - - // then - assertThat(review.getRating()).isEqualTo(1); - } - - @Test - void create_rating_최댓값_5() { - // when - Review review = Review.create(user, store, 5, "최고에요"); - - // then - assertThat(review.getRating()).isEqualTo(5); - } - - @Test - void create_초기에_삭제되지_않은_상태() { - // when - Review review = Review.create(user, store, 4, "맛있어요"); - - // then - assertThat(review.isDeleted()).isFalse(); - assertThat(review.getDeletedAt()).isNull(); - assertThat(review.getDeletedBy()).isNull(); - } - - // ─────────────────────────────────────────────── - // updateReview() - // ─────────────────────────────────────────────── - - @Test - void updateReview_rating과_content_변경됨() { - // given - Review review = Review.create(user, store, 3, "보통이에요"); - - // when - review.updateReview(5, "다시 먹어보니 최고에요"); - - // then - assertThat(review.getRating()).isEqualTo(5); - assertThat(review.getContent()).isEqualTo("다시 먹어보니 최고에요"); - } - - @Test - void updateReview_rating만_변경해도_content는_새값으로_설정() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when - review.updateReview(2, "맛있어요"); - - // then - assertThat(review.getRating()).isEqualTo(2); - assertThat(review.getContent()).isEqualTo("맛있어요"); - } - - @Test - void updateReview_rating이_null이면_기존값_유지() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when — rating null 전달 - review.updateReview(null, "새로운 내용"); - - // then — rating은 그대로 4, content만 변경됨 - assertThat(review.getRating()).isEqualTo(4); - assertThat(review.getContent()).isEqualTo("새로운 내용"); - } - - @Test - void updateReview_content가_blank이면_기존값_유지() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when — content 공백 전달 - review.updateReview(5, " "); - - // then — rating만 변경, content는 기존값 유지 - assertThat(review.getRating()).isEqualTo(5); - assertThat(review.getContent()).isEqualTo("맛있어요"); - } - - @Test - void updateReview_content가_null이면_기존값_유지() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when — content null 전달 - review.updateReview(5, null); - - // then — rating만 변경, content는 기존값 유지 - assertThat(review.getRating()).isEqualTo(5); - assertThat(review.getContent()).isEqualTo("맛있어요"); - } - - @Test - void updateReview_rating과_content_모두_null이면_아무것도_바뀌지_않음() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when - review.updateReview(null, null); - - // then - assertThat(review.getRating()).isEqualTo(4); - assertThat(review.getContent()).isEqualTo("맛있어요"); - } - - @Test - void updateReview_여러번_호출해도_마지막_값으로_덮어씀() { - // given - Review review = Review.create(user, store, 3, "처음엔 보통"); - - // when - review.updateReview(4, "두번째 수정"); - review.updateReview(5, "세번째 수정"); - - // then - assertThat(review.getRating()).isEqualTo(5); - assertThat(review.getContent()).isEqualTo("세번째 수정"); - } - - // ─────────────────────────────────────────────── - // delete() — BaseEntity 상속 - // ─────────────────────────────────────────────── - - @Test - void delete_삭제_처리후_deletedAt_설정됨() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - Long deletedByUserId = 1L; - - // when - review.delete(deletedByUserId); - - // then - assertThat(review.isDeleted()).isTrue(); - assertThat(review.getDeletedAt()).isNotNull(); - assertThat(review.getDeletedBy()).isEqualTo(deletedByUserId); - } - - @Test - void delete_이미_삭제된_리뷰는_deletedAt이_변경되지_않음() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - review.delete(1L); - var firstDeletedAt = review.getDeletedAt(); - - // when — 동일 리뷰에 다시 delete 호출 - review.delete(2L); - - // then — deletedAt, deletedBy 모두 최초값 유지 (idempotent) - assertThat(review.getDeletedAt()).isEqualTo(firstDeletedAt); - assertThat(review.getDeletedBy()).isEqualTo(1L); // 최초 삭제자 유지 - } - - @Test - void isDeleted_삭제전_false() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // then - assertThat(review.isDeleted()).isFalse(); - } - - @Test - void isDeleted_삭제후_true() { - // given - Review review = Review.create(user, store, 4, "맛있어요"); - - // when - review.delete(1L); - - // then - assertThat(review.isDeleted()).isTrue(); - } -} - diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapperTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapperTest.java index 64dad61..3cd0541 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapperTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/mapper/ReviewMapperTest.java @@ -1,5 +1,6 @@ package com.babjo.deliverycommerce.domain.review.mapper; +import com.babjo.deliverycommerce.domain.order.entity.Order; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; @@ -11,21 +12,32 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.List; +import java.util.UUID; + import static org.assertj.core.api.Assertions.assertThat; class ReviewMapperTest { + private static final Long OWNER_ID = 5L; + private ReviewMapper reviewMapper; - private Store store; - private User user; + private Store store; + private User user; + private Order order; private Review review; @BeforeEach void setUp() { reviewMapper = new ReviewMapper(); store = Store.create(1L, "한식", "테스트 식당", "서울시 강남구"); - user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); - review = Review.create(user, store, 4, "맛있어요"); + user = User.createForTest(1L, "testuser", "test@test.com", "테스터", UserEnumRole.CUSTOMER); + order = Order.createOrder(1L, UUID.randomUUID(), "Seoul", "msg", List.of()); + order.accept(OWNER_ID, 30); + order.startPreparing(OWNER_ID); + order.readyPickup(OWNER_ID); + order.completePickup(OWNER_ID); + review = Review.create(user, order, store, 4, "맛있어요"); } // ─────────────────────────────────────────────── @@ -40,6 +52,7 @@ void setUp() { // then assertThat(response.getReviewId()).isEqualTo(review.getReviewId()); assertThat(response.getUserId()).isEqualTo(user.getUserId()); + assertThat(response.getOrderId()).isEqualTo(order.getOrderId()); assertThat(response.getStoreId()).isEqualTo(store.getStoreId()); assertThat(response.getRating()).isEqualTo(4); assertThat(response.getContent()).isEqualTo("맛있어요"); @@ -67,26 +80,28 @@ void setUp() { ReviewCreateRequest request = new ReviewCreateRequest(); // when - Review entity = reviewMapper.toEntity(request, user, store); + Review entity = reviewMapper.toEntity(request, user, order, store); // then assertThat(entity).isNotNull(); assertThat(entity.getUser()).isEqualTo(user); + assertThat(entity.getOrder()).isEqualTo(order); assertThat(entity.getStore()).isEqualTo(store); assertThat(entity.getRating()).isEqualTo(request.getRating()); assertThat(entity.getContent()).isEqualTo(request.getContent()); } @Test - void toEntity_user와_store가_올바르게_설정됨() { + void toEntity_user와_order와_store가_올바르게_설정됨() { // given ReviewCreateRequest request = new ReviewCreateRequest(); // when - Review entity = reviewMapper.toEntity(request, user, store); + Review entity = reviewMapper.toEntity(request, user, order, store); // then assertThat(entity.getUser().getUserId()).isEqualTo(1L); + assertThat(entity.getOrder()).isEqualTo(order); assertThat(entity.getStore().getStoreId()).isEqualTo(store.getStoreId()); assertThat(entity.getStore().getName()).isEqualTo("테스트 식당"); } @@ -109,12 +124,12 @@ void setUp() { } @Test - void toCreateResponse_orderId는_Order_연동전_null() { + void toCreateResponse_orderId가_Order의_orderId와_일치() { // when ReviewCreateResponse response = reviewMapper.toCreateResponse(review); - // then — Order 연동 전이므로 orderId는 null이 정상 - assertThat(response.getOrderId()).isNull(); + // then — Order 연동 후이므로 orderId는 order의 orderId와 같아야 함 + assertThat(response.getOrderId()).isEqualTo(order.getOrderId()); } // ─────────────────────────────────────────────── diff --git a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java index af7e0b9..37977e4 100644 --- a/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java +++ b/src/test/java/com/babjo/deliverycommerce/domain/review/service/ReviewServiceTest.java @@ -1,4 +1,8 @@ package com.babjo.deliverycommerce.domain.review.service; + +import com.babjo.deliverycommerce.domain.order.entity.Order; +import com.babjo.deliverycommerce.domain.order.entity.OrderStatus; +import com.babjo.deliverycommerce.domain.order.repository.OrderRepository; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateRequest; import com.babjo.deliverycommerce.domain.review.dto.ReviewCreateResponse; import com.babjo.deliverycommerce.domain.review.dto.ReviewResponse; @@ -25,23 +29,30 @@ import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; + import java.util.List; import java.util.Optional; import java.util.UUID; + 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.eq; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.*; + @ExtendWith(MockitoExtension.class) class ReviewServiceTest { + @Mock private ReviewRepository reviewRepository; @Mock private StoreRepository storeRepository; @Mock private UserRepository userRepository; + @Mock private OrderRepository orderRepository; @Mock private ReviewMapper reviewMapper; + @InjectMocks private ReviewService reviewService; + private static final Long CUSTOMER_ID = 1L; private static final String CUSTOMER_ROLE = "ROLE_CUSTOMER"; private static final Long OTHER_ID = 2L; @@ -52,206 +63,398 @@ class ReviewServiceTest { private static final String MASTER_ROLE = "ROLE_MASTER"; private static final Long OWNER_ID = 5L; private static final String OWNER_ROLE = "ROLE_OWNER"; + private Store store; private User user; + private Order order; private Review review; + + // ─── 헬퍼 ─────────────────────────────────────────────────────── + private ReviewSearchRequest defaultSearch() { return new ReviewSearchRequest(); } + private ReviewSearchRequest searchByReviewId(UUID id) { ReviewSearchRequest s = new ReviewSearchRequest(); s.setReviewId(id); return s; } + private ReviewSearchRequest searchByStoreId(UUID id) { ReviewSearchRequest s = new ReviewSearchRequest(); s.setStoreId(id); return s; } + + private ReviewCreateRequest createReviewRequest(UUID orderId, UUID storeId, Integer rating, String content) { + try { + ReviewCreateRequest req = new ReviewCreateRequest(); + setField(req, "orderId", orderId); + setField(req, "storeId", storeId); + setField(req, "rating", rating); + setField(req, "content", content); + return req; + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private void setField(Object target, String fieldName, Object value) throws Exception { + var field = target.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + field.set(target, value); + } + @BeforeEach void setUp() { store = Store.create(1L, "korean", "Test Store", "Seoul"); user = User.createForTest(1L, "testuser", "test@test.com", "tester", UserEnumRole.CUSTOMER); - review = Review.create(user, store, 4, "good"); - } - // CREATE + + order = Order.createOrder(CUSTOMER_ID, UUID.randomUUID(), "Seoul", "no msg", List.of()); + order.accept(OWNER_ID, 30); + order.startPreparing(OWNER_ID); + order.readyPickup(OWNER_ID); + order.completePickup(OWNER_ID); + + review = Review.create(user, order, store, 4, "good"); + } + + // ─── CREATE ───────────────────────────────────────────────────── + @Test void createReview_success() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); ReviewCreateResponse expected = ReviewCreateResponse.builder().rating(4).content("good").build(); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); - given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); + given(reviewMapper.toEntity(any(), any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); given(reviewMapper.toCreateResponse(any())).willReturn(expected); + ReviewCreateResponse result = reviewService.createReview(CUSTOMER_ID, req); assertThat(result.getRating()).isEqualTo(4); assertThat(result.getContent()).isEqualTo("good"); verify(reviewRepository, times(1)).save(any()); } + @Test void createReview_store_stats_updated() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); ReviewCreateResponse expected = ReviewCreateResponse.builder().rating(4).content("good").build(); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); - given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); + given(reviewMapper.toEntity(any(), any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); given(reviewMapper.toCreateResponse(any())).willReturn(expected); + reviewService.createReview(CUSTOMER_ID, req); + assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(4.0); verify(storeRepository, times(1)).save(store); } + @Test void createReview_fail_user_not_found() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.USER_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } + + @Test + void createReview_fail_order_not_found() { + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.empty()); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.ORDER_NOT_FOUND)); + verify(reviewRepository, never()).save(any()); + } + + @Test + void createReview_fail_not_order_owner() { + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(OTHER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); // order.userId = CUSTOMER_ID + + assertThatThrownBy(() -> reviewService.createReview(OTHER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.NOT_ORDER_USER)); + verify(reviewRepository, never()).save(any()); + } + + @Test + void createReview_fail_order_not_completed_created_status() { + Order incompleteOrder = Order.createOrder(CUSTOMER_ID, UUID.randomUUID(), "Seoul", "msg", List.of()); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(incompleteOrder)); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.ORDER_NOT_COMPLETED)); + verify(reviewRepository, never()).save(any()); + } + + @Test + void createReview_fail_order_not_completed_accepted_status() { + Order acceptedOrder = Order.createOrder(CUSTOMER_ID, UUID.randomUUID(), "Seoul", "msg", List.of()); + acceptedOrder.accept(OWNER_ID, 30); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(acceptedOrder)); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.ORDER_NOT_COMPLETED)); + } + + @Test + void createReview_fail_order_not_completed_preparing_status() { + Order preparingOrder = Order.createOrder(CUSTOMER_ID, UUID.randomUUID(), "Seoul", "msg", List.of()); + preparingOrder.accept(OWNER_ID, 30); + preparingOrder.startPreparing(OWNER_ID); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(preparingOrder)); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.ORDER_NOT_COMPLETED)); + } + + @Test + void createReview_fail_order_canceled_status() { + Order canceledOrder = Order.createOrder(CUSTOMER_ID, UUID.randomUUID(), "Seoul", "msg", List.of()); + canceledOrder.cancel(CUSTOMER_ID, "change mind"); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(canceledOrder)); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.ORDER_NOT_COMPLETED)); + } + + @Test + void createReview_fail_duplicate_review() { + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(true); + + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) + .isInstanceOf(CustomException.class) + .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_ALREADY_EXISTS)); + verify(reviewRepository, never()).save(any()); + } + @Test void createReview_fail_store_not_found() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.STORE_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } + @Test void createReview_fail_soft_deleted_store() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.STORE_NOT_FOUND)); verify(storeRepository, never()).save(any()); verify(reviewRepository, never()).save(any()); } - // READ - single by reviewId + + // ─── READ - single by reviewId ─────────────────────────────────── + @Test void getReviews_customer_own_review_by_reviewId() { UUID reviewId = UUID.randomUUID(); ReviewResponse expected = ReviewResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId)); assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getReviewId()).isEqualTo(reviewId); assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); } + @Test void getReviews_customer_others_review_forbidden() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + assertThatThrownBy(() -> reviewService.getReviews(OTHER_ID, OTHER_ROLE, searchByReviewId(reviewId))) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); } + @Test void getReviews_manager_can_read_others_review() { UUID reviewId = UUID.randomUUID(); ReviewResponse expected = ReviewResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, searchByReviewId(reviewId)); assertThat(result.getContent()).hasSize(1); } + @Test void getReviews_master_can_read_others_review() { UUID reviewId = UUID.randomUUID(); ReviewResponse expected = ReviewResponse.builder() .reviewId(reviewId).userId(CUSTOMER_ID).rating(4).content("good").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, searchByReviewId(reviewId)); assertThat(result.getContent()).hasSize(1); } + @Test void getReviews_reviewId_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByReviewId(reviewId))) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } - // READ - list by storeId + + // ─── READ - list by storeId ────────────────────────────────────── + @Test void getReviews_by_storeId_success() { UUID storeId = UUID.randomUUID(); ReviewResponse expected = ReviewResponse.builder() .storeId(storeId).userId(CUSTOMER_ID).rating(3).content("ok").build(); Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any(Pageable.class))).willReturn(page); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getStoreId()).isEqualTo(storeId); } + @Test void getReviews_by_storeId_empty() { UUID storeId = UUID.randomUUID(); Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByStore_StoreId(eq(storeId), any(Pageable.class))).willReturn(emptyPage); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, searchByStoreId(storeId)); assertThat(result.getContent()).isEmpty(); } - // READ - no params (role-based) + + // ─── READ - no params (role-based) ─────────────────────────────── + @Test void getReviews_customer_no_params_returns_own() { ReviewResponse expected = ReviewResponse.builder().userId(CUSTOMER_ID).rating(4).content("good").build(); Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class))).willReturn(page); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); assertThat(result.getContent()).hasSize(1); assertThat(result.getContent().get(0).getUserId()).isEqualTo(CUSTOMER_ID); verify(reviewRepository, times(1)).findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class)); verify(reviewRepository, never()).findAll(any(Pageable.class)); } + @Test void getReviews_customer_no_params_empty() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByUser_UserId(eq(CUSTOMER_ID), any(Pageable.class))).willReturn(emptyPage); + Page result = reviewService.getReviews(CUSTOMER_ID, CUSTOMER_ROLE, defaultSearch()); assertThat(result.getContent()).isEmpty(); verify(reviewRepository, never()).findAll(any(Pageable.class)); } + @Test void getReviews_owner_no_params_returns_own() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAllByUser_UserId(eq(OWNER_ID), any(Pageable.class))).willReturn(emptyPage); + Page result = reviewService.getReviews(OWNER_ID, OWNER_ROLE, defaultSearch()); assertThat(result.getContent()).isEmpty(); verify(reviewRepository, times(1)).findAllByUser_UserId(eq(OWNER_ID), any(Pageable.class)); verify(reviewRepository, never()).findAll(any(Pageable.class)); } + @Test void getReviews_manager_no_params_returns_all() { ReviewResponse expected = ReviewResponse.builder().userId(CUSTOMER_ID).rating(4).content("good").build(); Page page = new PageImpl<>(List.of(review), PageRequest.of(0, 10), 1); + given(reviewRepository.findAll(any(Pageable.class))).willReturn(page); given(reviewMapper.toResponse(review)).willReturn(expected); + Page result = reviewService.getReviews(MANAGER_ID, MANAGER_ROLE, defaultSearch()); assertThat(result.getContent()).hasSize(1); verify(reviewRepository, times(1)).findAll(any(Pageable.class)); verify(reviewRepository, never()).findAllByUser_UserId(any(), any(Pageable.class)); } + @Test void getReviews_master_no_params_returns_all() { Page emptyPage = new PageImpl<>(List.of(), PageRequest.of(0, 10), 0); + given(reviewRepository.findAll(any(Pageable.class))).willReturn(emptyPage); + Page result = reviewService.getReviews(MASTER_ID, MASTER_ROLE, defaultSearch()); assertThat(result.getContent()).isEmpty(); verify(reviewRepository, times(1)).findAll(any(Pageable.class)); verify(reviewRepository, never()).findAllByUser_UserId(any(), any(Pageable.class)); } - // UPDATE + + // ─── UPDATE ───────────────────────────────────────────────────── + @Test void updateReview_success() { UUID reviewId = UUID.randomUUID(); @@ -260,22 +463,27 @@ void updateReview_success() { given(req.getContent()).willReturn("very good"); ReviewUpdateResponse expected = ReviewUpdateResponse.builder() .reviewId(reviewId).rating(5).content("very good").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); assertThat(result.getReviewId()).isEqualTo(reviewId); assertThat(result.getRating()).isEqualTo(5); } + @Test void updateReview_fail_not_owner() { UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + assertThatThrownBy(() -> reviewService.updateReview(OTHER_ID, OTHER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } + @Test void updateReview_manager_can_update_others() { UUID reviewId = UUID.randomUUID(); @@ -284,11 +492,14 @@ void updateReview_manager_can_update_others() { given(req.getContent()).willReturn("manager edit"); ReviewUpdateResponse expected = ReviewUpdateResponse.builder() .reviewId(reviewId).rating(3).content("manager edit").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(MANAGER_ID, MANAGER_ROLE, reviewId, req); assertThat(result.getRating()).isEqualTo(3); } + @Test void updateReview_master_can_update_others() { UUID reviewId = UUID.randomUUID(); @@ -297,36 +508,44 @@ void updateReview_master_can_update_others() { given(req.getContent()).willReturn("master edit"); ReviewUpdateResponse expected = ReviewUpdateResponse.builder() .reviewId(reviewId).rating(2).content("master edit").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + ReviewUpdateResponse result = reviewService.updateReview(MASTER_ID, MASTER_ROLE, reviewId, req); assertThat(result.getRating()).isEqualTo(2); } + @Test void updateReview_fail_owner_cannot_update_others() { UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + assertThatThrownBy(() -> reviewService.updateReview(OWNER_ID, OWNER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); } + @Test void updateReview_rating_change_updates_store_stats() { UUID reviewId = UUID.randomUUID(); - store.addReview(4); // count=1, avg=4.0 + store.addReview(4); ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(req.getRating()).willReturn(2); given(req.getContent()).willReturn("bad"); ReviewUpdateResponse expected = ReviewUpdateResponse.builder() .reviewId(reviewId).rating(2).content("bad").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); assertThat(store.getReviewCount()).isEqualTo(1); assertThat(store.getAverageRating()).isEqualTo(2.0); verify(storeRepository, times(1)).save(store); } + @Test void updateReview_same_rating_skips_store_save() { UUID reviewId = UUID.randomUUID(); @@ -335,121 +554,156 @@ void updateReview_same_rating_skips_store_save() { given(req.getContent()).willReturn("content only"); ReviewUpdateResponse expected = ReviewUpdateResponse.builder() .reviewId(reviewId).rating(4).content("content only").build(); + given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(reviewMapper.toUpdateResponse(review)).willReturn(expected); + reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req); verify(storeRepository, never()).save(any()); } + @Test void updateReview_fail_review_not_found() { UUID reviewId = UUID.randomUUID(); ReviewUpdateRequest req = mock(ReviewUpdateRequest.class); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); } - // DELETE (Soft Delete) + + // ─── DELETE (Soft Delete) ──────────────────────────────────────── + @Test void deleteReview_success() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); + assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(CUSTOMER_ID); verify(reviewRepository, times(1)).save(review); } + @Test void deleteReview_fail_not_owner() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OTHER_ID, OTHER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } + @Test void deleteReview_manager_can_delete_others() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + reviewService.deleteReview(reviewId, MANAGER_ID, MANAGER_ROLE); + assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(MANAGER_ID); } + @Test void deleteReview_master_can_delete_others() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + reviewService.deleteReview(reviewId, MASTER_ID, MASTER_ROLE); + assertThat(review.getDeletedAt()).isNotNull(); assertThat(review.getDeletedBy()).isEqualTo(MASTER_ID); } + @Test void deleteReview_fail_owner_cannot_delete_others() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, OWNER_ID, OWNER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_FORBIDDEN)); verify(reviewRepository, never()).save(any()); } + @Test void deleteReview_already_soft_deleted_returns_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); verify(storeRepository, never()).save(any()); verify(reviewRepository, never()).save(any()); } + @Test void deleteReview_store_stats_updated() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); + reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE); + assertThat(store.getReviewCount()).isEqualTo(0); assertThat(store.getAverageRating()).isEqualTo(0.0); verify(storeRepository, times(1)).save(store); } + @Test void deleteReview_fail_review_not_found() { UUID reviewId = UUID.randomUUID(); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.empty()); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) .isInstanceOf(CustomException.class) .satisfies(e -> assertThat(((CustomException) e).getErrorCode()).isEqualTo(ErrorCode.REVIEW_NOT_FOUND)); verify(reviewRepository, never()).save(any()); } - // Transaction rollback + + // ─── Transaction rollback ──────────────────────────────────────── + @Test void createReview_rollback_when_reviewRepository_save_throws() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); - given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); + given(reviewMapper.toEntity(any(), any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willThrow(new RuntimeException("db error")); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) - .isInstanceOf(RuntimeException.class) - .hasMessage("db error"); + .isInstanceOf(RuntimeException.class).hasMessage("db error"); verify(storeRepository, never()).save(store); } + @Test void createReview_rollback_when_storeRepository_save_throws() { - ReviewCreateRequest req = new ReviewCreateRequest(); + ReviewCreateRequest req = createReviewRequest(UUID.randomUUID(), UUID.randomUUID(), 4, "good"); + given(userRepository.findById(CUSTOMER_ID)).willReturn(Optional.of(user)); + given(orderRepository.findById(any())).willReturn(Optional.of(order)); + given(reviewRepository.existsByOrder_OrderId(any())).willReturn(false); given(storeRepository.findByStoreIdAndDeletedAtIsNull(any())).willReturn(Optional.of(store)); - given(reviewMapper.toEntity(any(), any(), any())).willReturn(review); + given(reviewMapper.toEntity(any(), any(), any(), any())).willReturn(review); given(reviewRepository.save(any())).willReturn(review); doThrow(new RuntimeException("store error")).when(storeRepository).save(store); + assertThatThrownBy(() -> reviewService.createReview(CUSTOMER_ID, req)) - .isInstanceOf(RuntimeException.class) - .hasMessage("store error"); + .isInstanceOf(RuntimeException.class).hasMessage("store error"); } + @Test void updateReview_rollback_when_storeRepository_save_throws() { UUID reviewId = UUID.randomUUID(); @@ -459,21 +713,23 @@ void updateReview_rollback_when_storeRepository_save_throws() { given(req.getContent()).willReturn("bad"); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); doThrow(new RuntimeException("store error")).when(storeRepository).save(any()); + assertThatThrownBy(() -> reviewService.updateReview(CUSTOMER_ID, CUSTOMER_ROLE, reviewId, req)) - .isInstanceOf(RuntimeException.class) - .hasMessage("store error"); + .isInstanceOf(RuntimeException.class).hasMessage("store error"); } + @Test void deleteReview_rollback_when_storeRepository_save_throws() { UUID reviewId = UUID.randomUUID(); store.addReview(4); given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); doThrow(new RuntimeException("store error")).when(storeRepository).save(store); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) - .isInstanceOf(RuntimeException.class) - .hasMessage("store error"); + .isInstanceOf(RuntimeException.class).hasMessage("store error"); verify(reviewRepository, never()).save(any()); } + @Test void deleteReview_rollback_when_reviewRepository_save_throws() { UUID reviewId = UUID.randomUUID(); @@ -481,24 +737,41 @@ void deleteReview_rollback_when_reviewRepository_save_throws() { given(reviewRepository.findByReviewId(reviewId)).willReturn(Optional.of(review)); given(storeRepository.save(store)).willReturn(store); doThrow(new RuntimeException("review error")).when(reviewRepository).save(review); + assertThatThrownBy(() -> reviewService.deleteReview(reviewId, CUSTOMER_ID, CUSTOMER_ROLE)) - .isInstanceOf(RuntimeException.class) - .hasMessage("review error"); + .isInstanceOf(RuntimeException.class).hasMessage("review error"); } - // ErrorCode pre-validation + + // ─── ErrorCode 검증 ────────────────────────────────────────────── + @Test void errorCode_REVIEW_ALREADY_EXISTS_is_defined() { assertThat(ErrorCode.REVIEW_ALREADY_EXISTS).isNotNull(); assertThat(ErrorCode.REVIEW_ALREADY_EXISTS.name()).isEqualTo("REVIEW_ALREADY_EXISTS"); } + @Test void errorCode_REVIEW_ALREADY_EXISTS_is_conflict_409() { CustomException ex = new CustomException(ErrorCode.REVIEW_ALREADY_EXISTS); assertThat(ex.getErrorCode().getStatus().value()).isEqualTo(409); } + @Test void errorCode_ORDER_NOT_FOUND_is_defined() { assertThat(ErrorCode.ORDER_NOT_FOUND).isNotNull(); assertThat(ErrorCode.ORDER_NOT_FOUND.getStatus().value()).isEqualTo(404); } -} \ No newline at end of file + + @Test + void errorCode_ORDER_NOT_COMPLETED_is_defined() { + assertThat(ErrorCode.ORDER_NOT_COMPLETED).isNotNull(); + assertThat(ErrorCode.ORDER_NOT_COMPLETED.getStatus().value()).isEqualTo(400); + } + + @Test + void errorCode_NOT_ORDER_USER_is_defined() { + assertThat(ErrorCode.NOT_ORDER_USER).isNotNull(); + assertThat(ErrorCode.NOT_ORDER_USER.getStatus().value()).isEqualTo(403); + } +} +