diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java index f50e17a1..579f59ec 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentController.java @@ -1,13 +1,17 @@ package com.eatsfine.eatsfine.domain.payment.controller; + import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; import com.eatsfine.eatsfine.domain.payment.service.PaymentService; + import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; 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.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -49,18 +53,21 @@ public ApiResponse cancelPayment( @Operation(summary = "결제 내역 조회", description = "로그인한 사용자의 결제 내역을 조회합니다.") @GetMapping public ApiResponse getPaymentList( - @RequestParam(name = "userId", required = false, defaultValue = "1") Long userId, + @AuthenticationPrincipal UserDetails user, @RequestParam(name = "page", defaultValue = "1") Integer page, @RequestParam(name = "limit", defaultValue = "10") Integer limit, @RequestParam(name = "status", required = false) String status) { - // TODO: userId는 추후 Security Context에서 가져오도록 수정 - return ApiResponse.onSuccess(paymentService.getPaymentList(userId, page, limit, status)); + String email = user.getUsername(); + return ApiResponse.onSuccess(paymentService.getPaymentList(email, page, limit, status)); } @Operation(summary = "결제 상세 조회", description = "특정 결제 건의 상세 내역을 조회합니다.") @GetMapping("/{paymentId}") public ApiResponse getPaymentDetail( + @AuthenticationPrincipal UserDetails user, @PathVariable Long paymentId) { - return ApiResponse.onSuccess(paymentService.getPaymentDetail(paymentId)); + String email = user.getUsername(); + return ApiResponse.onSuccess(paymentService.getPaymentDetail(paymentId, email)); } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java index 91cf70b6..53df1c9c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/repository/PaymentRepository.java @@ -5,15 +5,30 @@ 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 java.util.Optional; public interface PaymentRepository extends JpaRepository { - Optional findByOrderId(String orderId); + Optional findByOrderId(String orderId); - Optional findByPaymentKey(String paymentKey); + Optional findByPaymentKey(String paymentKey); - Page findAllByBooking_User_Id(Long userId, Pageable pageable); + @Query(value = "SELECT p FROM Payment p JOIN FETCH p.booking b JOIN FETCH b.store WHERE b.user.id = :userId", countQuery = "SELECT COUNT(p) FROM Payment p JOIN p.booking b WHERE b.user.id = :userId") + Page findAllByUserIdWithDetails(@Param("userId") Long userId, Pageable pageable); - Page findAllByBooking_User_IdAndPaymentStatus(Long userId, PaymentStatus status, Pageable pageable); + @Query(value = "SELECT p FROM Payment p JOIN FETCH p.booking b JOIN FETCH b.store WHERE b.user.id = :userId AND p.paymentStatus = :status", countQuery = "SELECT COUNT(p) FROM Payment p JOIN p.booking b WHERE b.user.id = :userId AND p.paymentStatus = :status") + Page findAllByUserIdAndStatusWithDetails(@Param("userId") Long userId, + @Param("status") PaymentStatus status, Pageable pageable); + + @Query(value = "SELECT p FROM Payment p JOIN FETCH p.booking b JOIN FETCH b.store s WHERE s.owner.id = :userId", countQuery = "SELECT COUNT(p) FROM Payment p JOIN p.booking b JOIN b.store s WHERE s.owner.id = :userId") + Page findAllByOwnerIdWithDetails(@Param("userId") Long userId, Pageable pageable); + + @Query(value = "SELECT p FROM Payment p JOIN FETCH p.booking b JOIN FETCH b.store s WHERE s.owner.id = :userId AND p.paymentStatus = :status", countQuery = "SELECT COUNT(p) FROM Payment p JOIN p.booking b JOIN b.store s WHERE s.owner.id = :userId AND p.paymentStatus = :status") + Page findAllByOwnerIdAndStatusWithDetails(@Param("userId") Long userId, + @Param("status") PaymentStatus status, Pageable pageable); + + @Query("SELECT p FROM Payment p JOIN FETCH p.booking b JOIN FETCH b.store JOIN FETCH b.user WHERE p.id = :paymentId") + Optional findByIdWithDetails(@Param("paymentId") Long paymentId); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java index 1a9bbf44..3cb68a28 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/service/PaymentService.java @@ -12,11 +12,16 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.enums.Role; import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -38,6 +43,7 @@ public class PaymentService { private final PaymentRepository paymentRepository; private final BookingRepository bookingRepository; + private final UserRepository userRepository; private final TossPaymentService tossPaymentService; @Transactional @@ -122,7 +128,6 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD log.info("Booking confirmed for OrderID: {}", dto.orderId()); } - log.info("Payment confirmed for OrderID: {}", dto.orderId()); return new PaymentResponseDTO.PaymentSuccessResultDTO( @@ -136,7 +141,6 @@ public PaymentResponseDTO.PaymentSuccessResultDTO confirmPayment(PaymentConfirmD payment.getReceiptUrl()); } - @Transactional(noRollbackFor = GeneralException.class) public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey, PaymentRequestDTO.CancelPaymentDTO dto) { @@ -162,8 +166,10 @@ public PaymentResponseDTO.CancelPaymentResultDTO cancelPayment(String paymentKey } @Transactional(readOnly = true) - public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Integer page, Integer limit, + public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(String email, Integer page, Integer limit, String status) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); // limit 기본값 처리 (만약 null이면 10) int size = (limit != null) ? limit : 10; // page 기본값 처리 (만약 null이면 1, 0보다 작으면 1로 보정). Spring Data는 0-based index이므로 -1 @@ -172,18 +178,32 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int Pageable pageable = PageRequest.of(pageNumber, size); Page paymentPage; - if (status != null && !status.isEmpty()) { - PaymentStatus paymentStatus; - try { - paymentStatus = PaymentStatus.valueOf(status.toUpperCase()); - } catch (IllegalArgumentException e) { - // 유효하지 않은 status가 들어오면 BadRequest 예외 발생 - throw new GeneralException(ErrorStatus._BAD_REQUEST); + if (user.getRole() == Role.ROLE_OWNER) { + if (status != null && !status.isEmpty()) { + PaymentStatus paymentStatus; + try { + paymentStatus = PaymentStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + paymentPage = paymentRepository.findAllByOwnerIdAndStatusWithDetails(user.getId(), + paymentStatus, pageable); + } else { + paymentPage = paymentRepository.findAllByOwnerIdWithDetails(user.getId(), pageable); } - paymentPage = paymentRepository.findAllByBooking_User_IdAndPaymentStatus(userId, paymentStatus, - pageable); } else { - paymentPage = paymentRepository.findAllByBooking_User_Id(userId, pageable); + if (status != null && !status.isEmpty()) { + PaymentStatus paymentStatus; + try { + paymentStatus = PaymentStatus.valueOf(status.toUpperCase()); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus._BAD_REQUEST); + } + paymentPage = paymentRepository.findAllByUserIdAndStatusWithDetails(user.getId(), + paymentStatus, pageable); + } else { + paymentPage = paymentRepository.findAllByUserIdWithDetails(user.getId(), pageable); + } } List payments = paymentPage.getContent().stream() @@ -211,10 +231,19 @@ public PaymentResponseDTO.PaymentListResponseDTO getPaymentList(Long userId, Int } @Transactional(readOnly = true) - public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId) { - Payment payment = paymentRepository.findById(paymentId) + public PaymentResponseDTO.PaymentDetailResultDTO getPaymentDetail(Long paymentId, String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + Payment payment = paymentRepository.findByIdWithDetails(paymentId) .orElseThrow(() -> new PaymentException(PaymentErrorStatus._PAYMENT_NOT_FOUND)); + boolean isBooker = payment.getBooking().getUser().getId().equals(user.getId()); + boolean isStoreOwner = payment.getBooking().getStore().getOwner().getId().equals(user.getId()); + + if (!isBooker && !isStoreOwner) { + throw new PaymentException(PaymentErrorStatus._PAYMENT_ACCESS_DENIED); + } + return new PaymentResponseDTO.PaymentDetailResultDTO( payment.getId(), payment.getBooking().getId(), diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java index e6dddaa8..0d8305d0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/status/PaymentErrorStatus.java @@ -13,7 +13,8 @@ public enum PaymentErrorStatus implements BaseErrorCode { _PAYMENT_INVALID_DEPOSIT(HttpStatus.BAD_REQUEST, "PAYMENT4001", "예약금이 유효하지 않습니다."), _PAYMENT_INVALID_AMOUNT(HttpStatus.BAD_REQUEST, "PAYMENT4002", "결제 금액이 일치하지 않습니다."), _PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PAYMENT4003", "결제 정보를 찾을 수 없습니다."), - _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."); + _BOOKING_NOT_FOUND(HttpStatus.NOT_FOUND, "BOOKING4001", "예약을 찾을 수 없습니다."), + _PAYMENT_ACCESS_DENIED(HttpStatus.FORBIDDEN, "PAYMENT4031", "해당 결제 정보에 접근할 권한이 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index 922e8e1b..5df0f1d3 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -60,14 +60,16 @@ spring: payment: toss: - widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + widget-secret-key: ${TOSS_WIDGET_SECRET_KEY} cloud: aws: - region: ap-northeast-2 + region: ${AWS_REGION} s3: - bucket: eatsfine-images - base-url: https://eatsfine-images.s3.ap-northeast-2.amazonaws.com + bucket: ${AWS_S3_BUCKET} + base-url: ${AWS_S3_BASE_URL} +api: + service-key: ${BIZ_API_KEY} jwt: secret: ${SECRET_KEY} diff --git a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java index fc997345..1e1c17b0 100644 --- a/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java +++ b/src/test/java/com/eatsfine/eatsfine/controller/HealthControllerTest.java @@ -10,7 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; -import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; import org.springframework.test.context.ActiveProfiles; @@ -41,13 +41,13 @@ class HealthControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter; - @MockBean + @MockitoBean private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - @MockBean + @MockitoBean private CustomAccessDeniedHandler customAccessDeniedHandler; @BeforeEach diff --git a/src/test/java/com/eatsfine/eatsfine/domain/inquiry/controller/InquiryControllerTest.java b/src/test/java/com/eatsfine/eatsfine/domain/inquiry/controller/InquiryControllerTest.java index 7d2c7eeb..dd3f028a 100644 --- a/src/test/java/com/eatsfine/eatsfine/domain/inquiry/controller/InquiryControllerTest.java +++ b/src/test/java/com/eatsfine/eatsfine/domain/inquiry/controller/InquiryControllerTest.java @@ -9,7 +9,7 @@ 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.http.MediaType; import org.springframework.security.test.context.support.WithMockUser; import org.springframework.test.web.servlet.MockMvc; @@ -42,16 +42,16 @@ class InquiryControllerTest { @Autowired private MockMvc mockMvc; - @MockBean + @MockitoBean private InquiryService inquiryService; - @MockBean + @MockitoBean private JwtAuthenticationFilter jwtAuthenticationFilter; - @MockBean + @MockitoBean private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; - @MockBean + @MockitoBean private CustomAccessDeniedHandler customAccessDeniedHandler; @Autowired diff --git a/src/test/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentControllerTest.java b/src/test/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentControllerTest.java new file mode 100644 index 00000000..df7a897c --- /dev/null +++ b/src/test/java/com/eatsfine/eatsfine/domain/payment/controller/PaymentControllerTest.java @@ -0,0 +1,187 @@ +package com.eatsfine.eatsfine.domain.payment.controller; + +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.service.PaymentService; +import com.eatsfine.eatsfine.global.auth.CustomAccessDeniedHandler; +import com.eatsfine.eatsfine.global.auth.CustomAuthenticationEntryPoint; +import com.eatsfine.eatsfine.global.config.jwt.JwtAuthenticationFilter; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.junit.jupiter.api.BeforeEach; +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.test.context.bean.override.mockito.MockitoBean; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.servlet.MockMvc; + +import java.io.IOException; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@WebMvcTest(PaymentController.class) +@WithMockUser +class PaymentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PaymentService paymentService; + + @MockitoBean + private JwtAuthenticationFilter jwtAuthenticationFilter; + + @MockitoBean + private CustomAuthenticationEntryPoint customAuthenticationEntryPoint; + + @MockitoBean + private CustomAccessDeniedHandler customAccessDeniedHandler; + + @Autowired + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() throws ServletException, IOException { + doAnswer(invocation -> { + HttpServletRequest request = invocation.getArgument(0); + HttpServletResponse response = invocation.getArgument(1); + FilterChain chain = invocation.getArgument(2); + chain.doFilter(request, response); + return null; + }).when(jwtAuthenticationFilter).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class), + any(FilterChain.class)); + } + + @Test + @DisplayName("결제 요청 성공") + void requestPayment_success() throws Exception { + // given + PaymentRequestDTO.RequestPaymentDTO request = new PaymentRequestDTO.RequestPaymentDTO(1L); + PaymentResponseDTO.PaymentRequestResultDTO response = new PaymentResponseDTO.PaymentRequestResultDTO( + 1L, 1L, "order-id-123", BigDecimal.valueOf(10000), LocalDateTime.now()); + + given(paymentService.requestPayment(any(PaymentRequestDTO.RequestPaymentDTO.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/v1/payments/request") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.paymentId").value(1L)) + .andExpect(jsonPath("$.result.orderId").value("order-id-123")); + } + + @Test + @DisplayName("결제 승인 성공") + void confirmPayment_success() throws Exception { + // given + PaymentConfirmDTO request = PaymentConfirmDTO.builder() + .paymentKey("payment-key-123") + .orderId("order-id-123") + .amount(BigDecimal.valueOf(10000)) + .build(); + + PaymentResponseDTO.PaymentSuccessResultDTO response = new PaymentResponseDTO.PaymentSuccessResultDTO( + 1L, "COMPLETED", LocalDateTime.now(), "order-id-123", BigDecimal.valueOf(10000), + "CARD", "TOSS", "http://receipt.url"); + + given(paymentService.confirmPayment(any(PaymentConfirmDTO.class))).willReturn(response); + + // when & then + mockMvc.perform(post("/api/v1/payments/confirm") + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.status").value("COMPLETED")); + } + + @Test + @DisplayName("결제 취소 성공") + void cancelPayment_success() throws Exception { + // given + String paymentKey = "payment-key-123"; + PaymentRequestDTO.CancelPaymentDTO request = new PaymentRequestDTO.CancelPaymentDTO("단순 변심"); + PaymentResponseDTO.CancelPaymentResultDTO response = new PaymentResponseDTO.CancelPaymentResultDTO( + 1L, "order-id-123", paymentKey, "CANCELED", LocalDateTime.now()); + + given(paymentService.cancelPayment(eq(paymentKey), any(PaymentRequestDTO.CancelPaymentDTO.class))) + .willReturn(response); + + // when & then + mockMvc.perform(post("/api/v1/payments/{paymentKey}/cancel", paymentKey) + .with(csrf()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.status").value("CANCELED")); + } + + @Test + @DisplayName("결제 내역 조회 성공") + void getPaymentList_success() throws Exception { + // given + PaymentResponseDTO.PaginationDTO pagination = new PaymentResponseDTO.PaginationDTO(1, 1, 1L); + PaymentResponseDTO.PaymentHistoryResultDTO history = new PaymentResponseDTO.PaymentHistoryResultDTO( + 1L, 1L, "Store Name", BigDecimal.valueOf(10000), "DEPOSIT", "CARD", "TOSS", "COMPLETED", + LocalDateTime.now()); + PaymentResponseDTO.PaymentListResponseDTO response = new PaymentResponseDTO.PaymentListResponseDTO( + Collections.singletonList(history), pagination); + + given(paymentService.getPaymentList(anyString(), any(Integer.class), any(Integer.class), any())) + .willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/payments") + .param("page", "1") + .param("limit", "10") + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.payments[0].storeName").value("Store Name")); + } + + @Test + @DisplayName("결제 상세 조회 성공") + void getPaymentDetail_success() throws Exception { + // given + Long paymentId = 1L; + PaymentResponseDTO.PaymentDetailResultDTO response = new PaymentResponseDTO.PaymentDetailResultDTO( + paymentId, 1L, "Store Name", "CARD", "TOSS", BigDecimal.valueOf(10000), "DEPOSIT", + "COMPLETED", LocalDateTime.now(), LocalDateTime.now(), "http://receipt.url", null); + + given(paymentService.getPaymentDetail(eq(paymentId), anyString())).willReturn(response); + + // when & then + mockMvc.perform(get("/api/v1/payments/{paymentId}", paymentId) + .contentType(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.result.paymentId").value(paymentId)); + } +} diff --git a/src/test/java/com/eatsfine/eatsfine/domain/payment/service/PaymentServiceTest.java b/src/test/java/com/eatsfine/eatsfine/domain/payment/service/PaymentServiceTest.java new file mode 100644 index 00000000..1f480d2f --- /dev/null +++ b/src/test/java/com/eatsfine/eatsfine/domain/payment/service/PaymentServiceTest.java @@ -0,0 +1,342 @@ +package com.eatsfine.eatsfine.domain.payment.service; + +import com.eatsfine.eatsfine.domain.booking.entity.Booking; +import com.eatsfine.eatsfine.domain.booking.repository.BookingRepository; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentConfirmDTO; +import com.eatsfine.eatsfine.domain.payment.dto.request.PaymentRequestDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.PaymentResponseDTO; +import com.eatsfine.eatsfine.domain.payment.dto.response.TossPaymentResponse; +import com.eatsfine.eatsfine.domain.payment.entity.Payment; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentMethod; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; +import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; +import com.eatsfine.eatsfine.domain.payment.exception.PaymentException; +import com.eatsfine.eatsfine.domain.payment.repository.PaymentRepository; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; +import com.eatsfine.eatsfine.domain.store.entity.Store; +import com.eatsfine.eatsfine.domain.payment.status.PaymentErrorStatus; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.enums.Role; +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class PaymentServiceTest { + + @InjectMocks + private PaymentService paymentService; + + @Mock + private PaymentRepository paymentRepository; + + @Mock + private BookingRepository bookingRepository; + + @Mock + private TossPaymentService tossPaymentService; + + @Mock + private UserRepository userRepository; + + @Test + @DisplayName("결제 요청 성공") + void requestPayment_success() { + // given + Long bookingId = 1L; + PaymentRequestDTO.RequestPaymentDTO request = new PaymentRequestDTO.RequestPaymentDTO(bookingId); + + Booking booking = Booking.builder() + .id(bookingId) + .build(); + ReflectionTestUtils.setField(booking, "depositAmount", BigDecimal.valueOf(10000)); + + Payment payment = Payment.builder() + .id(1L) + .booking(booking) + .amount(BigDecimal.valueOf(10000)) + .orderId("generated-order-id") + .paymentStatus(PaymentStatus.PENDING) + .requestedAt(LocalDateTime.now()) + .build(); + + given(bookingRepository.findById(bookingId)).willReturn(Optional.of(booking)); + given(paymentRepository.save(any(Payment.class))).willReturn(payment); + + // when + PaymentResponseDTO.PaymentRequestResultDTO response = paymentService.requestPayment(request); + + // then + assertThat(response.amount()).isEqualTo(BigDecimal.valueOf(10000)); + assertThat(response.orderId()).isEqualTo("generated-order-id"); + verify(paymentRepository, times(1)).save(any(Payment.class)); + } + + @Test + @DisplayName("결제 요청 실패 - 예약 없음") + void requestPayment_fail_bookingNotFound() { + // given + PaymentRequestDTO.RequestPaymentDTO request = new PaymentRequestDTO.RequestPaymentDTO(999L); + given(bookingRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> paymentService.requestPayment(request)) + .isInstanceOf(PaymentException.class) + .extracting("code") + .isEqualTo(PaymentErrorStatus._BOOKING_NOT_FOUND); + } + + @Test + @DisplayName("결제 승인 성공") + void confirmPayment_success() { + // given + String orderId = "order-id-123"; + String paymentKey = "payment-key-123"; + BigDecimal amount = BigDecimal.valueOf(10000); + PaymentConfirmDTO request = PaymentConfirmDTO.builder() + .orderId(orderId) + .amount(amount) + .paymentKey(paymentKey) + .build(); + + Booking booking = Booking.builder().id(1L).build(); + Payment payment = Payment.builder() + .id(1L) + .booking(booking) + .orderId(orderId) + .amount(amount) + .paymentStatus(PaymentStatus.PENDING) + .build(); + + TossPaymentResponse.EasyPay easyPay = new TossPaymentResponse.EasyPay("토스페이", 10000, 0); + TossPaymentResponse tossResponse = new TossPaymentResponse( + paymentKey, "NORMAL", orderId, "orderName", "mId", "KRW", "CARD", + 10000, 10000, "DONE", + java.time.OffsetDateTime.now(), java.time.OffsetDateTime.now(), + false, null, 10000, 0, + easyPay, new TossPaymentResponse.Receipt("http://receipt.url"));// TossPaymentResponse + // record 생성자가 많아서 + // 필드에 맞게 넣어줌 (가정) + // 실제 record 구조에 따라 맞춰야 + // 함. 위 내용은 예시. + // TossPaymentResponse가 + // record이므로 생성자 + // 파라미터 순서 중요. + // 여기서는 Mocking을 하거나, + // 필드가 많으면 빌더나 생성자를 + // 확인해야 함. + // TossPaymentService가 + // Mock이므로 response + // 리턴값만 잘 맞춰주면 됨. + + given(paymentRepository.findByOrderId(orderId)).willReturn(Optional.of(payment)); + given(tossPaymentService.confirm(any(PaymentConfirmDTO.class))).willReturn(tossResponse); + + // when + PaymentResponseDTO.PaymentSuccessResultDTO response = paymentService.confirmPayment(request); + + // then + assertThat(response.status()).isEqualTo(PaymentStatus.COMPLETED.name()); + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.COMPLETED); + } + + @Test + @DisplayName("결제 승인 실패 - 금액 불일치") + void confirmPayment_fail_invalidAmount() { + // given + String orderId = "order-id-123"; + BigDecimal originalAmount = BigDecimal.valueOf(10000); + BigDecimal requestAmount = BigDecimal.valueOf(5000); // Mismatch + + PaymentConfirmDTO request = PaymentConfirmDTO.builder() + .orderId(orderId) + .amount(requestAmount) + .paymentKey("key") + .build(); + + Payment payment = Payment.builder() + .id(1L) + .orderId(orderId) + .amount(originalAmount) + .paymentStatus(PaymentStatus.PENDING) + .build(); + + given(paymentRepository.findByOrderId(orderId)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.confirmPayment(request)) + .isInstanceOf(PaymentException.class) + .extracting("code") + .isEqualTo(PaymentErrorStatus._PAYMENT_INVALID_AMOUNT); + + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.FAILED); + } + + @Test + @DisplayName("결제 취소 성공") + void cancelPayment_success() { + // given + String paymentKey = "payment-key-123"; + PaymentRequestDTO.CancelPaymentDTO request = new PaymentRequestDTO.CancelPaymentDTO("단순 변심"); + + Payment payment = Payment.builder() + .id(1L) + .paymentKey(paymentKey) + .orderId("order-id-123") + .paymentStatus(PaymentStatus.COMPLETED) + .build(); + + TossPaymentResponse tossResponse = new TossPaymentResponse( + paymentKey, "NORMAL", "order-id-123", "orderName", "mId", "KRW", "CARD", + 10000, 0, "CANCELED", + java.time.OffsetDateTime.now(), java.time.OffsetDateTime.now(), + false, null, 10000, 0, + null, null); + given(paymentRepository.findByPaymentKey(paymentKey)).willReturn(Optional.of(payment)); + given(tossPaymentService.cancel(eq(paymentKey), any(PaymentRequestDTO.CancelPaymentDTO.class))) + .willReturn(tossResponse); + + // when + PaymentResponseDTO.CancelPaymentResultDTO response = paymentService.cancelPayment(paymentKey, request); + + // then + assertThat(response.status()).isEqualTo("REFUNDED"); + assertThat(payment.getPaymentStatus()).isEqualTo(PaymentStatus.REFUNDED); + } + + @Test + @DisplayName("결제 내역 조회 - 손님") + void getPaymentList_Customer_success() { + // given + User user = User.builder().id(1L).role(Role.ROLE_CUSTOMER).build(); + // Pageable pageable = PageRequest.of(0, 10); + + Payment payment = Payment.builder() + .id(1L) + .booking(Booking.builder().id(1L).store(Store.builder().storeName("Store").build()) + .build()) + .amount(BigDecimal.valueOf(10000)) + .paymentStatus(PaymentStatus.COMPLETED) + .paymentType(PaymentType.DEPOSIT) + .paymentMethod(PaymentMethod.SIMPLE_PAYMENT) + .paymentProvider(PaymentProvider.TOSS) + .approvedAt(LocalDateTime.now()) + .build(); + + Page paymentPage = new PageImpl<>(List.of(payment)); + + given(userRepository.findByEmail("customer")).willReturn(Optional.of(user)); + given(paymentRepository.findAllByUserIdWithDetails(eq(1L), any(Pageable.class))) + .willReturn(paymentPage); + + // when + PaymentResponseDTO.PaymentListResponseDTO response = paymentService.getPaymentList("customer", 1, 10, + null); + + // then + assertThat(response.payments()).hasSize(1); + assertThat(response.payments().get(0).storeName()).isEqualTo("Store"); + verify(paymentRepository, times(1)).findAllByUserIdWithDetails(eq(1L), any(Pageable.class)); + } + + @Test + @DisplayName("결제 내역 조회 - 사장님") + void getPaymentList_Owner_success() { + // given + User user = User.builder().id(2L).role(Role.ROLE_OWNER).build(); + // Pageable pageable = PageRequest.of(0, 10); + + Payment payment = Payment.builder() + .id(2L) + .booking(Booking.builder().id(1L) + .store(Store.builder().storeName("My Store").owner(user).build()) + .build()) + .amount(BigDecimal.valueOf(20000)) + .paymentStatus(PaymentStatus.COMPLETED) + .paymentType(PaymentType.DEPOSIT) + .paymentMethod(PaymentMethod.SIMPLE_PAYMENT) + .paymentProvider(PaymentProvider.TOSS) + .approvedAt(LocalDateTime.now()) + .build(); + + Page paymentPage = new PageImpl<>(List.of(payment)); + + given(userRepository.findByEmail("owner")).willReturn(Optional.of(user)); + given(paymentRepository.findAllByOwnerIdWithDetails(eq(2L), any(Pageable.class))) + .willReturn(paymentPage); + + // when + PaymentResponseDTO.PaymentListResponseDTO response = paymentService.getPaymentList("owner", 1, 10, + null); + + // then + assertThat(response.payments()).hasSize(1); + assertThat(response.payments().get(0).storeName()).isEqualTo("My Store"); + verify(paymentRepository, times(1)).findAllByOwnerIdWithDetails(eq(2L), any(Pageable.class)); + } + + @Test + @DisplayName("결제 상세 조회 실패 - 사용자 없음") + void getPaymentDetail_UserNotFound() { + // given + String email = "unknown@example.com"; + given(userRepository.findByEmail(email)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> paymentService.getPaymentDetail(1L, email)) + .isInstanceOf(UserException.class) + .extracting("code") + .isEqualTo(UserErrorStatus.MEMBER_NOT_FOUND); + } + + @Test + @DisplayName("결제 상세 조회 실패 - 권한 없음 (남의 결제 내역)") + void getPaymentDetail_AccessDenied() { + // given + String email = "hacker@example.com"; + User hacker = User.builder().id(999L).role(Role.ROLE_CUSTOMER).build(); + User owner = User.builder().id(1L).role(Role.ROLE_CUSTOMER).build(); + User storeOwner = User.builder().id(2L).role(Role.ROLE_OWNER).build(); + + Store store = Store.builder().id(1L).owner(storeOwner).build(); + Booking booking = Booking.builder().user(owner).store(store).build(); // owner(1L) is booker, + // storeOwner(2L) is owner + Payment payment = Payment.builder() + .id(1L) + .booking(booking) + .build(); + + given(userRepository.findByEmail(email)).willReturn(Optional.of(hacker)); + given(paymentRepository.findByIdWithDetails(1L)).willReturn(Optional.of(payment)); + + // when & then + assertThatThrownBy(() -> paymentService.getPaymentDetail(1L, email)) + .isInstanceOf(PaymentException.class) + .extracting("code") + .isEqualTo(PaymentErrorStatus._PAYMENT_ACCESS_DENIED); + } +}