diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 21463a11..f5a117f8 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -31,7 +31,7 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: 레포지토리 체크아웃 uses: actions/checkout@v4 @@ -80,11 +80,46 @@ jobs: python3 -c "import yaml, sys; yaml.safe_load(open('$file'))" || exit 1 done + - name: Docker 진단 + run: | + set -eux + echo "=== docker version ===" + docker --version + echo "=== docker info ===" + docker info + echo "=== docker.sock ===" + ls -al /var/run/docker.sock || true + echo "=== env grep docker ===" + env | sort | grep -E 'DOCKER|TESTCONTAINERS' || true + + - name: Identity 진단 + run: | + set -eux + id + groups + + - name: Relax docker.sock permission (CI) + run: | + set -eux + sudo chmod 666 /var/run/docker.sock + ls -al /var/run/docker.sock + + - name: docker-java API version 강제 변경 (Docker 29 workaround) + run: | + set -eux + echo "HOME=$HOME" + mkdir -p "$HOME/.docker-java" + echo "api.version=1.44" > "$HOME/.docker-java.properties" + cat "$HOME/.docker-java.properties" + - name: 빌드 및 테스트 (${{ inputs.module-name }}) run: ./gradlew :${{ inputs.module-path }}:build --stacktrace + env: + # CI 환경에서 Testcontainers의 리소스 정리 컨테이너(Ryuk) 권한 문제를 방지합니다. + TESTCONTAINERS_RYUK_DISABLED: true - name: 테스트 리포트 업로드 - if: failure() + if: always() uses: actions/upload-artifact@v4 with: name: test-report-${{ inputs.module-name }} diff --git a/payment-gateway/build.gradle b/payment-gateway/build.gradle index 620d8454..4dbefbac 100644 --- a/payment-gateway/build.gradle +++ b/payment-gateway/build.gradle @@ -25,7 +25,9 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-aop' implementation 'io.projectreactor:reactor-core' + implementation 'io.github.resilience4j:resilience4j-spring-boot3:2.2.0' // Spring Metrics & Actuator implementation 'org.springframework.boot:spring-boot-starter-actuator' @@ -36,9 +38,9 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'com.squareup.okhttp3:mockwebserver:4.11.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } - tasks.named('test') { useJUnitPlatform() -} +} \ No newline at end of file diff --git a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayController.java b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayController.java index 460f3d45..bb60a79b 100644 --- a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayController.java +++ b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayController.java @@ -30,9 +30,27 @@ public ResponseEntity confirm( return response; } + @PostMapping("/payments/cancel") + public ResponseEntity cancel( + @RequestParam(defaultValue = "toss") String provider, + @RequestBody CancelRequest request + ) { + log.info("[Gateway] cancel 요청 수신 - provider={}, request={}", provider, request); + ResponseEntity response = ResponseEntity.ok(router.cancel(provider, request)); + log.info("[Gateway] cancel 처리 완료 - response={}", response); + return response; + } + public record ConfirmRequest(String paymentKey, String orderId, int amount) { } public record ConfirmResponse(boolean isSuccess, int totalAmount, String errorCode, String errorMessage) { } + + public record CancelRequest(String orderId, int cancelAmount, String cancelReason) { + } + + public record CancelResponse(boolean isSuccess, int cancelAmount, String transactionKey, String errorCode, + String errorMessage) { + } } diff --git a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/PaymentProvider.java b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/PaymentProvider.java index 1c65f8fa..768f950d 100644 --- a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/PaymentProvider.java +++ b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/PaymentProvider.java @@ -6,4 +6,6 @@ public interface PaymentProvider { boolean supports(String providerName); ConfirmResponse confirm(ConfirmRequest request); + + CancelResponse cancel(CancelRequest request); } diff --git a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/ProviderRouter.java b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/ProviderRouter.java index 8de88e92..bfac57c4 100644 --- a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/ProviderRouter.java +++ b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/ProviderRouter.java @@ -16,10 +16,17 @@ public ProviderRouter(List providers) { } public ConfirmResponse confirm(String providerName, ConfirmRequest request) { + return findProvider(providerName).confirm(request); + } + + public CancelResponse cancel(String providerName, CancelRequest request) { + return findProvider(providerName).cancel(request); + } + + private PaymentProvider findProvider(String providerName) { return providers.stream() .filter(provider -> provider.supports(providerName)) .findFirst() - .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 결제수단입니다.")) - .confirm(request); + .orElseThrow(() -> new IllegalArgumentException("지원하지 않는 결제수단입니다.")); } } diff --git a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProvider.java b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProvider.java index ee2e8684..56c2f329 100644 --- a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProvider.java +++ b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProvider.java @@ -18,4 +18,9 @@ public boolean supports(String providerName) { public ConfirmResponse confirm(ConfirmRequest request) { return new ConfirmResponse(true, request.amount(), null, null); } + + @Override + public CancelResponse cancel(CancelRequest request) { + return new CancelResponse(true, request.cancelAmount(), "stub-tx-key", null, null); + } } \ No newline at end of file diff --git a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProvider.java b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProvider.java index 74eeb83f..685c93ba 100644 --- a/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProvider.java +++ b/payment-gateway/src/main/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProvider.java @@ -5,30 +5,50 @@ import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; +import io.github.resilience4j.bulkhead.BulkheadFullException; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.extern.slf4j.Slf4j; + +@Slf4j @Component -@Profile("prod") +@Profile({"prod","test"}) public class TossPaymentProvider implements PaymentProvider { private final RestClient restClient; + private final ConcurrentHashMap paymentKeyStore = new ConcurrentHashMap<>(); - public TossPaymentProvider(RestClient.Builder builder, @Value("${toss.secret-key}") String secretKey) { + public TossPaymentProvider( + RestClient.Builder builder, + @Value("${toss.secret-key}") String secretKey, + @Value("${toss.base-url:https://api.tosspayments.com}") String baseUrl + ) { if (secretKey == null || secretKey.isEmpty()) { throw new IllegalStateException("toss.secret-key가 설정되어 있지 않습니다."); } String encoded = Base64.getEncoder() .encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8)); + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(2_000); + factory.setReadTimeout(3_000); + this.restClient = builder - .baseUrl("https://api.tosspayments.com") + .baseUrl(baseUrl) .defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encoded) + .requestFactory(factory) .build(); } @@ -38,6 +58,8 @@ public boolean supports(String providerName) { } @Override + @CircuitBreaker(name = "tossConfirm", fallbackMethod = "confirmFallback") + @Bulkhead(name = "tossConfirm") public ConfirmResponse confirm(ConfirmRequest request) { Map response = restClient.post() .uri("/v1/payments/confirm") @@ -52,6 +74,7 @@ public ConfirmResponse confirm(ConfirmRequest request) { String status = (String)response.getOrDefault("status", ""); if ("DONE".equals(status)) { + paymentKeyStore.put(request.orderId(), request.paymentKey()); int totalAmount = (int)response.getOrDefault("totalAmount", request.amount()); return new ConfirmResponse(true, totalAmount, null, null); } @@ -59,4 +82,53 @@ public ConfirmResponse confirm(ConfirmRequest request) { String msg = (String)response.getOrDefault("message", "토스 승인 실패"); return new ConfirmResponse(false, 0, code, msg); } + + private ConfirmResponse confirmFallback(ConfirmRequest request, CallNotPermittedException e) { + log.warn("Toss 결제 승인 차단됨 [CIRCUIT_BREAKER_OPEN]"); + return new ConfirmResponse(false, 0, "PG_TEMPORARY_UNAVAILABLE", "결제 서비스가 일시적으로 불안정합니다."); + } + + private ConfirmResponse confirmFallback(ConfirmRequest request, BulkheadFullException e) { + log.warn("Toss 결제 승인 차단됨 [BULKHEAD_FULL]"); + return new ConfirmResponse(false, 0, "PG_TEMPORARY_UNAVAILABLE", "결제 요청이 집중되고 있습니다."); + } + + private ConfirmResponse confirmFallback(ConfirmRequest request, Throwable e) { + log.warn("Toss 결제 승인 실패: {}", e.toString()); + return new ConfirmResponse(false, 0, "PG_NETWORK_ERROR", "PG 통신 중 오류가 발생했습니다."); + } + + @Override + public CancelResponse cancel(CancelRequest request) { + String paymentKey = paymentKeyStore.get(request.orderId()); + if (paymentKey == null) { + return new CancelResponse(false, 0, null, "PAYMENT_KEY_NOT_FOUND", + "결제 키를 찾을 수 없습니다. orderId=" + request.orderId()); + } + + try { + Map response = restClient.post() + .uri("/v1/payments/{paymentKey}/cancel", paymentKey) + .contentType(MediaType.APPLICATION_JSON) + .body(Map.of( + "cancelReason", request.cancelReason(), + "cancelAmount", request.cancelAmount()) + ) + .retrieve() + .body(Map.class); + + String status = (String)response.getOrDefault("status", ""); + if ("CANCELED".equals(status) || "PARTIAL_CANCELED".equals(status)) { + String transactionKey = (String)response.getOrDefault("transactionKey", ""); + return new CancelResponse(true, request.cancelAmount(), transactionKey, null, null); + } + + String code = (String)response.getOrDefault("code", "TOSS_ERROR"); + String msg = (String)response.getOrDefault("message", "토스 취소 실패"); + return new CancelResponse(false, 0, null, code, msg); + } catch (HttpClientErrorException e) { + log.error("토스 취소 API 호출 실패 - orderId: {}, error: {}", request.orderId(), e.getResponseBodyAsString()); + return new CancelResponse(false, 0, null, "TOSS_CANCEL_ERROR", e.getResponseBodyAsString()); + } + } } diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/PaymentGatewayApplicationTests.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/PaymentGatewayApplicationTests.java index a39ca081..a6cc6ec7 100644 --- a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/PaymentGatewayApplicationTests.java +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/PaymentGatewayApplicationTests.java @@ -2,8 +2,10 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; @SpringBootTest +@ActiveProfiles("dev") class PaymentGatewayApplicationTests { @Test diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayControllerTest.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayControllerTest.java new file mode 100644 index 00000000..759dab19 --- /dev/null +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/api/PaymentGatewayControllerTest.java @@ -0,0 +1,94 @@ +package wisoft.nextframe.paymentgateway.api; + +import static org.mockito.BDDMockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; + +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.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import wisoft.nextframe.paymentgateway.provider.ProviderRouter; + +@WebMvcTest(PaymentGatewayController.class) +@DisplayName("PaymentGatewayController 테스트") +class PaymentGatewayControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ProviderRouter router; + + @Test + @DisplayName("POST /payments/confirm 성공 시 200 OK를 반환한다") + void confirm_success() throws Exception { + given(router.confirm(eq("toss"), any(ConfirmRequest.class))) + .willReturn(new ConfirmResponse(true, 10000, null, null)); + + mockMvc.perform(post("/api/v1/gateway/payments/confirm") + .param("provider", "toss") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"paymentKey": "pk_123", "orderId": "order-1", "amount": 10000} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.totalAmount").value(10000)); + } + + @Test + @DisplayName("POST /payments/cancel 성공 시 200 OK를 반환한다") + void cancel_success() throws Exception { + given(router.cancel(eq("toss"), any(CancelRequest.class))) + .willReturn(new CancelResponse(true, 10000, "tx_abc", null, null)); + + mockMvc.perform(post("/api/v1/gateway/payments/cancel") + .param("provider", "toss") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"orderId": "order-1", "cancelAmount": 10000, "cancelReason": "단순 변심"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.cancelAmount").value(10000)) + .andExpect(jsonPath("$.transactionKey").value("tx_abc")); + } + + @Test + @DisplayName("POST /payments/cancel 실패 시에도 200 OK에 에러 정보를 반환한다") + void cancel_failure() throws Exception { + given(router.cancel(eq("toss"), any(CancelRequest.class))) + .willReturn(new CancelResponse(false, 0, null, "ALREADY_CANCELED", "이미 취소됨")); + + mockMvc.perform(post("/api/v1/gateway/payments/cancel") + .param("provider", "toss") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"orderId": "order-1", "cancelAmount": 10000, "cancelReason": "환불"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.errorCode").value("ALREADY_CANCELED")); + } + + @Test + @DisplayName("provider 파라미터 생략 시 기본값 toss로 동작한다") + void cancel_defaultProvider() throws Exception { + given(router.cancel(eq("toss"), any(CancelRequest.class))) + .willReturn(new CancelResponse(true, 5000, "tx_def", null, null)); + + mockMvc.perform(post("/api/v1/gateway/payments/cancel") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + {"orderId": "order-2", "cancelAmount": 5000, "cancelReason": "테스트"} + """)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)); + } +} \ No newline at end of file diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/ProviderRouterTest.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/ProviderRouterTest.java new file mode 100644 index 00000000..1ffa5a0a --- /dev/null +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/ProviderRouterTest.java @@ -0,0 +1,58 @@ +package wisoft.nextframe.paymentgateway.provider; + +import static org.assertj.core.api.Assertions.*; +import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; + +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("ProviderRouter 단위 테스트") +class ProviderRouterTest { + + private final StubPaymentProvider stubProvider = new StubPaymentProvider(); + private final ProviderRouter router = new ProviderRouter(List.of(stubProvider)); + + @Test + @DisplayName("지원하는 provider로 confirm 요청 시 정상 라우팅된다") + void confirm_routesToCorrectProvider() { + ConfirmRequest request = new ConfirmRequest("key", "order-1", 10000); + + ConfirmResponse response = router.confirm("toss", request); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.totalAmount()).isEqualTo(10000); + } + + @Test + @DisplayName("지원하는 provider로 cancel 요청 시 정상 라우팅된다") + void cancel_routesToCorrectProvider() { + CancelRequest request = new CancelRequest("order-1", 10000, "환불 사유"); + + CancelResponse response = router.cancel("toss", request); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.cancelAmount()).isEqualTo(10000); + } + + @Test + @DisplayName("지원하지 않는 provider로 confirm 요청 시 예외가 발생한다") + void confirm_unsupportedProvider_throwsException() { + ConfirmRequest request = new ConfirmRequest("key", "order-1", 10000); + + assertThatThrownBy(() -> router.confirm("unknown", request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("지원하지 않는 결제수단입니다."); + } + + @Test + @DisplayName("지원하지 않는 provider로 cancel 요청 시 예외가 발생한다") + void cancel_unsupportedProvider_throwsException() { + CancelRequest request = new CancelRequest("order-1", 10000, "환불 사유"); + + assertThatThrownBy(() -> router.cancel("unknown", request)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("지원하지 않는 결제수단입니다."); + } +} \ No newline at end of file diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProviderTest.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProviderTest.java new file mode 100644 index 00000000..3a9b85a6 --- /dev/null +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/StubPaymentProviderTest.java @@ -0,0 +1,51 @@ +package wisoft.nextframe.paymentgateway.provider; + +import static org.assertj.core.api.Assertions.*; +import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("StubPaymentProvider 단위 테스트") +class StubPaymentProviderTest { + + private final StubPaymentProvider provider = new StubPaymentProvider(); + + @Test + @DisplayName("toss provider를 지원한다") + void supports_toss() { + assertThat(provider.supports("toss")).isTrue(); + assertThat(provider.supports("TOSS")).isTrue(); + } + + @Test + @DisplayName("toss 외 provider는 지원하지 않는다") + void doesNotSupport_other() { + assertThat(provider.supports("kakaopay")).isFalse(); + } + + @Test + @DisplayName("confirm은 항상 성공을 반환한다") + void confirm_alwaysSuccess() { + ConfirmRequest request = new ConfirmRequest("key", "order-1", 10000); + + ConfirmResponse response = provider.confirm(request); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.totalAmount()).isEqualTo(10000); + assertThat(response.errorCode()).isNull(); + } + + @Test + @DisplayName("cancel은 항상 성공을 반환한다") + void cancel_alwaysSuccess() { + CancelRequest request = new CancelRequest("order-1", 10000, "테스트 환불"); + + CancelResponse response = provider.cancel(request); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.cancelAmount()).isEqualTo(10000); + assertThat(response.transactionKey()).isEqualTo("stub-tx-key"); + assertThat(response.errorCode()).isNull(); + } +} \ No newline at end of file diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderResilienceTest.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderResilienceTest.java new file mode 100644 index 00000000..a45592b7 --- /dev/null +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderResilienceTest.java @@ -0,0 +1,173 @@ +package wisoft.nextframe.paymentgateway.provider; + +import static org.assertj.core.api.Assertions.*; +import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterAll; +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.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.SocketPolicy; + +@DisplayName("TossPaymentProvider 복원력 패턴 통합 테스트") +@SpringBootTest +@ActiveProfiles("test") +class TossPaymentProviderResilienceTest { + + static MockWebServer mockWebServer; + + @Autowired + PaymentProvider provider; + + @Autowired + CircuitBreakerRegistry circuitBreakerRegistry; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) { + mockWebServer = new MockWebServer(); + try { + mockWebServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + registry.add("toss.base-url", () -> mockWebServer.url("/").toString()); + registry.add("toss.secret-key", () -> "test_secret_key"); + } + + @BeforeEach + void resetCircuitBreaker() { + circuitBreakerRegistry.circuitBreaker("tossConfirm").reset(); + } + + @AfterAll + static void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + @DisplayName("readTimeout 초과 시 PG_NETWORK_ERROR fallback을 반환한다") + void confirm_timeout_returnsFallback() { + mockWebServer.enqueue(new MockResponse() + .setBodyDelay(5, TimeUnit.SECONDS) + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 1000} + """) + .addHeader("Content-Type", "application/json")); + + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_timeout", "order-timeout", 1000)); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("PG_NETWORK_ERROR"); + assertThat(response.errorMessage()).contains("PG 통신 중 오류"); + } + + @Test + @DisplayName("연속 네트워크 실패 후 서킷 브레이커가 OPEN 상태가 되면 PG_TEMPORARY_UNAVAILABLE을 반환한다") + void confirm_circuitBreakerOpens_afterNetworkFailures() { + // minimumNumberOfCalls=2, failureRateThreshold=50 + // 연결 끊김 2회 → ResourceAccessException → 실패율 100% → CB OPEN + for (int i = 0; i < 2; i++) { + mockWebServer.enqueue(new MockResponse() + .setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); + + provider.confirm(new ConfirmRequest("pk_" + i, "order-cb-" + i, 1000)); + } + + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("tossConfirm"); + assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + // OPEN 상태에서는 PG 호출 없이 즉시 fallback 반환 + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_blocked", "order-blocked", 1000)); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("PG_TEMPORARY_UNAVAILABLE"); + assertThat(response.errorMessage()).contains("일시적으로 불안정"); + } + + @Test + @DisplayName("성공 호출은 서킷 브레이커를 OPEN 상태로 전이시키지 않는다") + void confirm_successDoesNotOpenCircuitBreaker() { + for (int i = 0; i < 4; i++) { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 1000} + """) + .addHeader("Content-Type", "application/json")); + + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_" + i, "order-ok-" + i, 1000)); + + assertThat(response.isSuccess()).isTrue(); + } + + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("tossConfirm"); + assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("네트워크 실패와 성공이 혼합될 때 실패율이 임계치 미만이면 서킷 브레이커는 CLOSED 상태를 유지한다") + void confirm_mixedResults_circuitBreakerRemainsClosed() { + // slidingWindowSize=4, minimumNumberOfCalls=2, failureRateThreshold=50 + // 성공 → 성공 → 실패 → 성공 순으로 호출하면 + // 최종 실패율 1/4 = 25% < 50% → CLOSED 유지 + // (실패를 먼저 넣으면 2회차(1/2=50%) 시점에 CB가 OPEN되므로 성공을 먼저 배치) + + // 2회 성공 + for (int i = 0; i < 2; i++) { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 1000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_" + i, "order-mix-" + i, 1000)); + } + + // 1회 네트워크 실패 + mockWebServer.enqueue(new MockResponse() + .setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST)); + provider.confirm(new ConfirmRequest("pk_fail", "order-mix-2", 1000)); + + // 1회 성공 + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 1000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_3", "order-mix-3", 1000)); + + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("tossConfirm"); + assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.CLOSED); + } + + @Test + @DisplayName("PG 서버가 응답하지 않으면 PG_NETWORK_ERROR를 반환한다") + void confirm_noResponse_returnsFallback() { + mockWebServer.enqueue(new MockResponse() + .setSocketPolicy(SocketPolicy.NO_RESPONSE)); + + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_dead", "order-dead", 1000)); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("PG_NETWORK_ERROR"); + } +} diff --git a/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderTest.java b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderTest.java new file mode 100644 index 00000000..353935e7 --- /dev/null +++ b/payment-gateway/src/test/java/wisoft/nextframe/paymentgateway/provider/TossPaymentProviderTest.java @@ -0,0 +1,186 @@ +package wisoft.nextframe.paymentgateway.provider; + +import static org.assertj.core.api.Assertions.*; +import static wisoft.nextframe.paymentgateway.api.PaymentGatewayController.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.web.client.RestClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +@DisplayName("TossPaymentProvider 단위 테스트") +class TossPaymentProviderTest { + + private MockWebServer mockWebServer; + private TossPaymentProvider provider; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + provider = new TossPaymentProvider( + RestClient.builder(), + "test_secret_key", + mockWebServer.url("/").toString() + ); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Test + @DisplayName("confirm 성공 시 paymentKey를 저장하고 성공 응답을 반환한다") + void confirm_success_storesPaymentKey() { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 10000} + """) + .addHeader("Content-Type", "application/json")); + + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_test_123", "order-1", 10000)); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.totalAmount()).isEqualTo(10000); + } + + @Test + @DisplayName("confirm 실패 시 에러 정보를 반환한다") + void confirm_failure_returnsError() { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "FAILED", "code": "REJECT_CARD_PAYMENT", "message": "카드 거절"} + """) + .addHeader("Content-Type", "application/json")); + + ConfirmResponse response = provider.confirm( + new ConfirmRequest("pk_test_123", "order-1", 10000)); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("REJECT_CARD_PAYMENT"); + } + + @Test + @DisplayName("cancel 시 paymentKey가 없으면 PAYMENT_KEY_NOT_FOUND를 반환한다") + void cancel_noPaymentKey_returnsNotFound() { + CancelResponse response = provider.cancel( + new CancelRequest("unknown-order", 10000, "환불")); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("PAYMENT_KEY_NOT_FOUND"); + } + + @Test + @DisplayName("confirm 후 cancel 시 토스 API를 호출하여 성공 응답을 반환한다") + void cancel_afterConfirm_success() { + // confirm으로 paymentKey 저장 + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 10000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_test_123", "order-1", 10000)); + + // cancel 성공 + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "CANCELED", "transactionKey": "tx_abc123"} + """) + .addHeader("Content-Type", "application/json")); + + CancelResponse response = provider.cancel( + new CancelRequest("order-1", 10000, "단순 변심")); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.cancelAmount()).isEqualTo(10000); + assertThat(response.transactionKey()).isEqualTo("tx_abc123"); + } + + @Test + @DisplayName("부분 취소(PARTIAL_CANCELED) 상태도 성공으로 처리한다") + void cancel_partialCanceled_success() { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 10000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_test_123", "order-1", 10000)); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "PARTIAL_CANCELED", "transactionKey": "tx_partial"} + """) + .addHeader("Content-Type", "application/json")); + + CancelResponse response = provider.cancel( + new CancelRequest("order-1", 6000, "부분 환불")); + + assertThat(response.isSuccess()).isTrue(); + assertThat(response.cancelAmount()).isEqualTo(6000); + } + + @Test + @DisplayName("cancel 시 토스 API가 실패 상태를 반환하면 실패 응답을 반환한다") + void cancel_tossReturnsFailure() { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 10000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_test_123", "order-1", 10000)); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "FAILED", "code": "ALREADY_CANCELED_PAYMENT", "message": "이미 취소된 결제"} + """) + .addHeader("Content-Type", "application/json")); + + CancelResponse response = provider.cancel( + new CancelRequest("order-1", 10000, "환불")); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("ALREADY_CANCELED_PAYMENT"); + } + + @Test + @DisplayName("cancel 시 토스 API가 HTTP 에러를 반환하면 TOSS_CANCEL_ERROR를 반환한다") + void cancel_httpError_returnsError() { + mockWebServer.enqueue(new MockResponse() + .setResponseCode(200) + .setBody(""" + {"status": "DONE", "totalAmount": 10000} + """) + .addHeader("Content-Type", "application/json")); + provider.confirm(new ConfirmRequest("pk_test_123", "order-1", 10000)); + + mockWebServer.enqueue(new MockResponse() + .setResponseCode(400) + .setBody(""" + {"code": "INVALID_REQUEST", "message": "잘못된 요청"} + """) + .addHeader("Content-Type", "application/json")); + + CancelResponse response = provider.cancel( + new CancelRequest("order-1", 10000, "환불")); + + assertThat(response.isSuccess()).isFalse(); + assertThat(response.errorCode()).isEqualTo("TOSS_CANCEL_ERROR"); + } +} diff --git a/payment/build.gradle b/payment/build.gradle index eb003475..dde176c2 100644 --- a/payment/build.gradle +++ b/payment/build.gradle @@ -42,9 +42,9 @@ dependencies { implementation "io.github.resilience4j:resilience4j-spring-boot3:2.2.0" // Test Containers - testImplementation "org.testcontainers:testcontainers:1.20.1" - testImplementation "org.testcontainers:junit-jupiter:1.20.1" - testImplementation "org.testcontainers:postgresql:1.20.1" + testImplementation platform("org.testcontainers:testcontainers-bom:2.0.2") + testImplementation "org.testcontainers:junit-jupiter" + testImplementation "org.testcontainers:postgresql" testImplementation "com.squareup.okhttp3:mockwebserver:4.11.0" testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/PaymentService.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/PaymentService.java index 5427e8ca..d521089e 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/PaymentService.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/PaymentService.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; +import wisoft.nextframe.payment.application.payment.port.output.TicketingClient; import wisoft.nextframe.payment.domain.payment.Payment; import wisoft.nextframe.payment.presentation.payment.dto.PaymentConfirmRequest; @@ -15,17 +16,48 @@ public class PaymentService { private final PaymentGateway paymentGateway; + private final TicketingClient ticketingClient; private final PaymentTransactionService paymentTransactionService; public Payment confirmPayment(PaymentConfirmRequest request) { - // 트랜잭션 없이 외부 호출 + // 1. 트랜잭션 없이 외부 PG 호출 PaymentGateway.PaymentConfirmResult result = paymentGateway.confirmPayment( request.paymentKey(), request.orderId(), request.amount() ); + // PG 실패 시 바로 FAILED 저장 + if (!result.isSuccess()) { + return paymentTransactionService.applyConfirmResult(request, result); + } + + // 2. PG 성공 → 티켓 발급 동기 호출 + try { + ticketingClient.issueTicket( + wisoft.nextframe.payment.domain.ReservationId.of( + java.util.UUID.fromString(request.orderId()) + ) + ); + } catch (Exception e) { + log.error("티켓 발급 실패 - orderId={}, error={}", request.orderId(), e.getMessage()); + + // best-effort PG 취소 + try { + paymentGateway.cancelPayment(request.orderId(), request.amount(), "티켓 발급 실패"); + } catch (Exception cancelEx) { + log.error("PG 취소도 실패 - orderId={}, error={} (수동 확인 필요)", request.orderId(), cancelEx.getMessage()); + } + + // FAILED 결과로 저장 + PaymentGateway.PaymentConfirmResult failedResult = new PaymentGateway.PaymentConfirmResult( + false, result.totalAmount(), "TICKET_ISSUE_FAILED", e.getMessage() + ); + return paymentTransactionService.applyConfirmResult(request, failedResult); + } + + // 3. 티켓 발급 성공 → Payment SUCCEEDED 저장 return paymentTransactionService.applyConfirmResult(request, result); } -} \ No newline at end of file +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayExternalCallFailedException.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayExternalCallFailedException.java new file mode 100644 index 00000000..268d0e7b --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayExternalCallFailedException.java @@ -0,0 +1,7 @@ +package wisoft.nextframe.payment.application.payment.exception; + +public class PaymentGatewayExternalCallFailedException extends RuntimeException { + public PaymentGatewayExternalCallFailedException(String operation, Throwable cause) { + super("결제 게이트웨이 외부 호출 실패. operation=" + operation, cause); + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayTemporarilyUnavailableException.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayTemporarilyUnavailableException.java new file mode 100644 index 00000000..2d931178 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/PaymentGatewayTemporarilyUnavailableException.java @@ -0,0 +1,7 @@ +package wisoft.nextframe.payment.application.payment.exception; + +public class PaymentGatewayTemporarilyUnavailableException extends RuntimeException { + public PaymentGatewayTemporarilyUnavailableException(String operation, Throwable cause) { + super("결제 게이트웨이가 일시적으로 불가능합니다. operation=" + operation, cause); + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueExternalCallFailedException.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueExternalCallFailedException.java similarity index 81% rename from payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueExternalCallFailedException.java rename to payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueExternalCallFailedException.java index e03bc44b..e1de54f5 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueExternalCallFailedException.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueExternalCallFailedException.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; +package wisoft.nextframe.payment.application.payment.exception; import wisoft.nextframe.payment.domain.ReservationId; diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueInvalidResponseException.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueInvalidResponseException.java similarity index 81% rename from payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueInvalidResponseException.java rename to payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueInvalidResponseException.java index 42fa7e54..f174f5c5 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueInvalidResponseException.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueInvalidResponseException.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; +package wisoft.nextframe.payment.application.payment.exception; import wisoft.nextframe.payment.domain.ReservationId; diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueTemporarilyUnavailableException.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueTemporarilyUnavailableException.java similarity index 82% rename from payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueTemporarilyUnavailableException.java rename to payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueTemporarilyUnavailableException.java index eba8d9a5..d20ed7a5 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueTemporarilyUnavailableException.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/exception/TicketIssueTemporarilyUnavailableException.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; +package wisoft.nextframe.payment.application.payment.exception; import wisoft.nextframe.payment.domain.ReservationId; diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/handler/PaymentEventHandler.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/handler/PaymentEventHandler.java index 7dbc563a..ee0ddbba 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/handler/PaymentEventHandler.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/handler/PaymentEventHandler.java @@ -7,8 +7,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import wisoft.nextframe.payment.application.payment.outbox.cancel.ReservationCancelOutboxService; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueOutboxService; -import wisoft.nextframe.payment.domain.payment.event.PaymentApprovedEvent; import wisoft.nextframe.payment.domain.payment.event.PaymentFailedEvent; @Slf4j @@ -16,18 +14,11 @@ @RequiredArgsConstructor public class PaymentEventHandler { - private final TicketIssueOutboxService ticketIssueOutboxService; private final ReservationCancelOutboxService reservationCancelOutboxService; - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onPaymentApproved(PaymentApprovedEvent event) { - log.info("결제 승인 이벤트 처리 - paymentId={}, reservationId={}", event.paymentId(), event.reservationId()); - ticketIssueOutboxService.issueOrEnqueue(event.paymentId(), event.reservationId()); - } - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void onPaymentFailed(PaymentFailedEvent event) { log.info("결제 실패 이벤트 처리 - paymentId={}, reservationId={}", event.paymentId(), event.reservationId()); reservationCancelOutboxService.cancelOrEnqueue(event.paymentId(), event.reservationId()); } -} \ No newline at end of file +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxRepository.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxRepository.java deleted file mode 100644 index b0ddd7f1..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxRepository.java +++ /dev/null @@ -1,11 +0,0 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; - -import java.time.LocalDateTime; -import java.util.UUID; - -import wisoft.nextframe.payment.application.payment.outbox.OutboxRepository; - -public interface TicketIssueOutboxRepository extends OutboxRepository { - - void markSuccess(UUID reservationId, UUID ticketId, LocalDateTime now); -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxService.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxService.java deleted file mode 100644 index efdcaedb..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxService.java +++ /dev/null @@ -1,42 +0,0 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; - -import java.time.LocalDateTime; -import java.util.UUID; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.port.output.TicketingClient; -import wisoft.nextframe.payment.domain.ReservationId; - -@Slf4j -@Service -@RequiredArgsConstructor -public class TicketIssueOutboxService { - - private final TicketingClient ticketingClient; - private final TicketIssueOutboxRepository outboxRepository; - - @Transactional(propagation = Propagation.REQUIRES_NEW) - public void issueOrEnqueue(UUID paymentId, UUID reservationId) { - log.debug("outboxRepository class={}", outboxRepository.getClass()); - - LocalDateTime now = LocalDateTime.now(); - - // 1) 먼저 outbox에 PENDING upsert (쓰기 선반영) - outboxRepository.upsertPending(paymentId, reservationId, null, now); - try { - // 2) 외부 호출 - TicketIssueResult response = ticketingClient.issueTicket(ReservationId.of(reservationId)); - - // 3) 성공 처리 - outboxRepository.markSuccess(reservationId, response.ticketId(), now); - } catch (Exception e) { - // 4) 실패면 lastError만 업데이트 (이미 PENDING row는 있음) - outboxRepository.upsertPending(paymentId, reservationId, e.toString(), now); - } - } -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxTarget.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxTarget.java deleted file mode 100644 index 72e76166..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueOutboxTarget.java +++ /dev/null @@ -1,8 +0,0 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; - -import java.util.UUID; - -import wisoft.nextframe.payment.application.payment.outbox.OutboxTarget; - -public record TicketIssueOutboxTarget(UUID reservationId, UUID paymentId) implements OutboxTarget { -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueRetryUseCase.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueRetryUseCase.java deleted file mode 100644 index d0d50098..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueRetryUseCase.java +++ /dev/null @@ -1,38 +0,0 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; - -import java.time.LocalDateTime; - -import org.springframework.stereotype.Service; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.outbox.AbstractOutboxRetryUseCase; -import wisoft.nextframe.payment.application.payment.outbox.OutboxRepository; -import wisoft.nextframe.payment.application.payment.port.output.TicketingClient; -import wisoft.nextframe.payment.domain.ReservationId; - -@Slf4j -@Service -@RequiredArgsConstructor -public class TicketIssueRetryUseCase extends AbstractOutboxRetryUseCase { - - private final TicketIssueOutboxRepository outboxRepository; - private final TicketingClient ticketingClient; - - @Override - protected OutboxRepository getRepository() { - return outboxRepository; - } - - @Override - protected void executeExternalCall(TicketIssueOutboxTarget target, LocalDateTime now) { - TicketIssueResult response = ticketingClient.issueTicket(ReservationId.of(target.reservationId())); - outboxRepository.markSuccess(target.reservationId(), response.ticketId(), now); - log.info("티켓 발급 재시도 성공 - reservationId={}", target.reservationId()); - } - - @Override - protected String getLogPrefix() { - return "ticket_issue_retry"; - } -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/PaymentGateway.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/PaymentGateway.java index 56b27145..6181df0f 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/PaymentGateway.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/PaymentGateway.java @@ -3,6 +3,8 @@ public interface PaymentGateway { PaymentConfirmResult confirmPayment(String paymentKey, String orderId, int amount); + PaymentCancelResult cancelPayment(String orderId, int cancelAmount, String cancelReason); + record PaymentConfirmResult( boolean isSuccess, int totalAmount, @@ -10,4 +12,13 @@ record PaymentConfirmResult( String errorMessage ) { } + + record PaymentCancelResult( + boolean isSuccess, + int cancelAmount, + String transactionKey, + String errorCode, + String errorMessage + ) { + } } diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/ReservationReader.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/ReservationReader.java index f333b927..1d7296ba 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/ReservationReader.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/ReservationReader.java @@ -1,7 +1,11 @@ package wisoft.nextframe.payment.application.payment.port.output; +import java.time.LocalDateTime; + import wisoft.nextframe.payment.domain.ReservationId; public interface ReservationReader { boolean exists(ReservationId reservationId); + + LocalDateTime getPerformanceDateTime(ReservationId reservationId); } diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueResult.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketIssueResult.java similarity index 51% rename from payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueResult.java rename to payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketIssueResult.java index 94562eab..10ebc481 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/outbox/ticketissue/TicketIssueResult.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketIssueResult.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.application.payment.outbox.ticketissue; +package wisoft.nextframe.payment.application.payment.port.output; import java.util.UUID; diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketingClient.java b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketingClient.java index 113db629..4fd9c8cd 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketingClient.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/payment/port/output/TicketingClient.java @@ -1,6 +1,5 @@ package wisoft.nextframe.payment.application.payment.port.output; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueResult; import wisoft.nextframe.payment.domain.ReservationId; public interface TicketingClient { diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundCancelFailedException.java b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundCancelFailedException.java new file mode 100644 index 00000000..87c1a888 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundCancelFailedException.java @@ -0,0 +1,14 @@ +package wisoft.nextframe.payment.application.refund; + +import lombok.Getter; + +@Getter +public class RefundCancelFailedException extends RuntimeException { + + private final String errorCode; + + public RefundCancelFailedException(String errorCode) { + super("PG 환불 요청이 실패했습니다. errorCode=" + errorCode); + this.errorCode = errorCode; + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundService.java b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundService.java index b01ab1e2..d8fda764 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundService.java +++ b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundService.java @@ -1,32 +1,46 @@ package wisoft.nextframe.payment.application.refund; -import java.time.LocalDateTime; +import java.util.UUID; -import wisoft.nextframe.payment.domain.payment.Payment; -import wisoft.nextframe.payment.domain.payment.PaymentIssuer; +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; +import wisoft.nextframe.payment.application.refund.RefundTransactionService.RefundPrepareResult; import wisoft.nextframe.payment.domain.refund.Refund; -// 환불 절차를 통제하는 역할 +@Slf4j +@Service +@RequiredArgsConstructor public class RefundService { - private final PaymentIssuer paymentIssuer; + private final PaymentGateway paymentGateway; + private final RefundTransactionService refundTransactionService; - public RefundService(PaymentIssuer paymentIssuer) { - this.paymentIssuer = paymentIssuer; - } + public Refund refund(UUID paymentId, String reason) { + // 1. 환불 준비 (검증 + Refund 생성, 트랜잭션) + RefundPrepareResult prepareResult = refundTransactionService.prepareRefund(paymentId); - public Refund refund(Payment payment, LocalDateTime requestAt, LocalDateTime contentStartsAt) { - // 1. 정책에 따라 환불 가능 여부 판단 및 Refund 생성 - if (payment == null) { - throw new IllegalArgumentException("Payment cannot be null"); + if (prepareResult.alreadyRefunded()) { + return prepareResult.refund(); } - Refund refund = paymentIssuer.issueRefund(payment, requestAt, contentStartsAt); - // 2. 승인 처리 - refund.approve(); + Refund refund = prepareResult.refund(); + String orderId = prepareResult.payment().getReservationId().value().toString(); + int cancelAmount = refund.getRefundedAmount().getValue().intValue(); - // 3. 결과 반환 - return refund; - } + // 2. PG 환불 요청 (트랜잭션 없이 외부 호출) + PaymentGateway.PaymentCancelResult cancelResult = + paymentGateway.cancelPayment(orderId, cancelAmount, reason); -} \ No newline at end of file + if (!cancelResult.isSuccess()) { + log.error("PG 환불 실패 - paymentId: {}, errorCode: {}, errorMessage: {}", + paymentId, cancelResult.errorCode(), cancelResult.errorMessage()); + throw new RefundCancelFailedException(cancelResult.errorCode()); + } + + // 3. 환불 완료 저장 (트랜잭션) + return refundTransactionService.completeRefund(paymentId, refund, reason); + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundTransactionService.java b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundTransactionService.java new file mode 100644 index 00000000..58354e85 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/application/refund/RefundTransactionService.java @@ -0,0 +1,93 @@ +package wisoft.nextframe.payment.application.refund; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import wisoft.nextframe.payment.application.payment.port.output.PaymentRepository; +import wisoft.nextframe.payment.application.payment.port.output.ReservationReader; +import wisoft.nextframe.payment.application.refund.port.output.RefundRepository; +import wisoft.nextframe.payment.domain.payment.Payment; +import wisoft.nextframe.payment.domain.payment.PaymentId; +import wisoft.nextframe.payment.domain.payment.PaymentIssuer; +import wisoft.nextframe.payment.domain.payment.PaymentNotFoundException; +import wisoft.nextframe.payment.domain.refund.Refund; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefundTransactionService { + + private final PaymentRepository paymentRepository; + private final RefundRepository refundRepository; + private final ReservationReader reservationReader; + private final PaymentIssuer paymentIssuer; + + /** + * 환불 준비: 검증 + Refund 생성 (PG 호출 전 단계) + * 멱등성 체크 후 이미 환불된 경우 기존 환불을 포함한 결과를 반환한다. + */ + @Transactional(readOnly = true) + public RefundPrepareResult prepareRefund(UUID paymentId) { + // 1. Payment 조회 + Payment payment = paymentRepository.findById(PaymentId.of(paymentId)) + .orElseThrow(PaymentNotFoundException::new); + + // 2. 멱등성 체크 - 이미 환불된 경우 + Optional existingRefund = refundRepository.findByPaymentId(paymentId); + if (existingRefund.isPresent()) { + log.warn("이미 환불된 결제 - paymentId: {}", paymentId); + return RefundPrepareResult.alreadyRefunded(existingRefund.get()); + } + + // 3. 공연 시작 시간 조회 + LocalDateTime performanceDateTime = reservationReader.getPerformanceDateTime(payment.getReservationId()); + + // 4. 환불 발급 (정책 결정 + Refund 생성 + 환불 가능 검증 + payment.assignRefund) + LocalDateTime now = LocalDateTime.now(); + Refund refund = paymentIssuer.issueRefund(payment, now, performanceDateTime); + + return RefundPrepareResult.prepared(payment, refund); + } + + /** + * 환불 완료: PG 승인 후 DB 저장 + */ + @Transactional + public Refund completeRefund(UUID paymentId, Refund refund, String reason) { + // 1. Payment 재조회 (트랜잭션 컨텍스트 내에서) + Payment payment = paymentRepository.findById(PaymentId.of(paymentId)) + .orElseThrow(PaymentNotFoundException::new); + + // 2. 승인 처리 + refund.approve(); + payment.assignRefund(refund); + + // 3. 저장 + log.info("환불 처리 완료 - paymentId: {}, refundId: {}, refundAmount: {}", + paymentId, refund.getRefundId().getValue(), refund.getRefundedAmount()); + refundRepository.save(refund, paymentId, reason); + paymentRepository.save(payment); + + return refund; + } + + public record RefundPrepareResult( + boolean alreadyRefunded, + Payment payment, + Refund refund + ) { + public static RefundPrepareResult alreadyRefunded(Refund existingRefund) { + return new RefundPrepareResult(true, null, existingRefund); + } + + public static RefundPrepareResult prepared(Payment payment, Refund refund) { + return new RefundPrepareResult(false, payment, refund); + } + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/application/refund/port/output/RefundRepository.java b/payment/src/main/java/wisoft/nextframe/payment/application/refund/port/output/RefundRepository.java new file mode 100644 index 00000000..72cca1e6 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/application/refund/port/output/RefundRepository.java @@ -0,0 +1,12 @@ +package wisoft.nextframe.payment.application.refund.port.output; + +import java.util.Optional; +import java.util.UUID; + +import wisoft.nextframe.payment.domain.refund.Refund; + +public interface RefundRepository { + Refund save(Refund refund, UUID paymentId, String reason); + + Optional findByPaymentId(UUID paymentId); +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/Payment.java b/payment/src/main/java/wisoft/nextframe/payment/domain/payment/Payment.java index 474d7086..b265f22b 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/Payment.java +++ b/payment/src/main/java/wisoft/nextframe/payment/domain/payment/Payment.java @@ -11,7 +11,6 @@ import wisoft.nextframe.payment.common.exception.InvalidAmountException; import wisoft.nextframe.payment.domain.ReservationId; import wisoft.nextframe.payment.domain.payment.event.DomainEvent; -import wisoft.nextframe.payment.domain.payment.event.PaymentApprovedEvent; import wisoft.nextframe.payment.domain.payment.event.PaymentFailedEvent; import wisoft.nextframe.payment.domain.payment.exception.InvalidPaymentStatusException; import wisoft.nextframe.payment.domain.payment.exception.MissingReservationException; @@ -89,8 +88,6 @@ public void approve() { } this.status = PaymentStatus.SUCCEEDED; this.approvedAt = LocalDateTime.now(); - - domainEvents.add(new PaymentApprovedEvent(this.id.getValue(), this.reservationId.value())); } public void fail() { diff --git a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/PaymentIssuer.java b/payment/src/main/java/wisoft/nextframe/payment/domain/payment/PaymentIssuer.java index ded14591..2ef9d4c2 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/PaymentIssuer.java +++ b/payment/src/main/java/wisoft/nextframe/payment/domain/payment/PaymentIssuer.java @@ -2,10 +2,13 @@ import java.time.LocalDateTime; +import org.springframework.stereotype.Component; + import wisoft.nextframe.payment.domain.payment.exception.InvalidPaymentStatusException; import wisoft.nextframe.payment.domain.payment.exception.RefundAlreadyExistsException; import wisoft.nextframe.payment.domain.refund.Refund; +@Component public class PaymentIssuer { public Refund issueRefund(Payment payment, LocalDateTime requestAt, LocalDateTime performanceStartsAt) { diff --git a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/event/PaymentApprovedEvent.java b/payment/src/main/java/wisoft/nextframe/payment/domain/payment/event/PaymentApprovedEvent.java deleted file mode 100644 index 0394e0d3..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/domain/payment/event/PaymentApprovedEvent.java +++ /dev/null @@ -1,7 +0,0 @@ -package wisoft.nextframe.payment.domain.payment.event; - -import java.util.UUID; - -public record PaymentApprovedEvent(UUID paymentId, UUID reservationId) implements DomainEvent { - -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/global/PaymentGlobalExceptionHandler.java b/payment/src/main/java/wisoft/nextframe/payment/global/PaymentGlobalExceptionHandler.java index 585c2dc0..c26c3b8c 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/global/PaymentGlobalExceptionHandler.java +++ b/payment/src/main/java/wisoft/nextframe/payment/global/PaymentGlobalExceptionHandler.java @@ -9,6 +9,8 @@ import wisoft.nextframe.payment.application.payment.exception.ReservationNotFoundException; import wisoft.nextframe.payment.domain.payment.exception.PaymentException; import wisoft.nextframe.payment.domain.refund.exception.RefundException; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayExternalCallFailedException; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayTemporarilyUnavailableException; @Slf4j @RestControllerAdvice @@ -35,6 +37,22 @@ public ResponseEntity handleReservationNotFoundException(Reservat .body(new ErrorResponse("RESERVATION_NOT_FOUND", ex.getMessage())); } + @ExceptionHandler(PaymentGatewayTemporarilyUnavailableException.class) + public ResponseEntity handlePaymentGatewayUnavailable(PaymentGatewayTemporarilyUnavailableException ex) { + log.warn("결제 게이트웨이 일시 불가: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.SERVICE_UNAVAILABLE) + .body(new ErrorResponse("PAYMENT_GATEWAY_UNAVAILABLE", "결제 서비스가 일시적으로 불가능합니다. 잠시 후 다시 시도해 주세요.")); + } + + @ExceptionHandler(PaymentGatewayExternalCallFailedException.class) + public ResponseEntity handlePaymentGatewayCallFailed(PaymentGatewayExternalCallFailedException ex) { + log.error("결제 게이트웨이 호출 실패: {}", ex.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_GATEWAY) + .body(new ErrorResponse("PAYMENT_GATEWAY_FAILED", "결제 처리 중 외부 서비스 오류가 발생했습니다.")); + } + @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception ex) { log.error("Unexpected error occurred: ", ex); diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/DbReservationReader.java b/payment/src/main/java/wisoft/nextframe/payment/infra/DbReservationReader.java index fdde501f..9ee6495e 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/DbReservationReader.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/DbReservationReader.java @@ -1,5 +1,7 @@ package wisoft.nextframe.payment.infra; +import java.time.LocalDateTime; + import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; @@ -22,4 +24,15 @@ public boolean exists(ReservationId reservationId) { ); return Boolean.TRUE.equals(exists); } + + @Override + public LocalDateTime getPerformanceDateTime(ReservationId reservationId) { + return jdbcTemplate.queryForObject( + "SELECT s.performance_datetime FROM reservations r " + + "JOIN schedules s ON r.schedule_id = s.id " + + "WHERE r.id = ?", + LocalDateTime.class, + reservationId.value() + ); + } } \ No newline at end of file diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/PaymentMapper.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/PaymentMapper.java index 26d175fe..35e03aab 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/PaymentMapper.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/PaymentMapper.java @@ -2,23 +2,36 @@ import org.springframework.stereotype.Component; +import lombok.RequiredArgsConstructor; import wisoft.nextframe.payment.common.Money; import wisoft.nextframe.payment.common.mapper.EntityMapper; import wisoft.nextframe.payment.domain.payment.Payment; import wisoft.nextframe.payment.domain.payment.PaymentId; import wisoft.nextframe.payment.domain.ReservationId; +import wisoft.nextframe.payment.domain.refund.Refund; +import wisoft.nextframe.payment.infra.refund.JpaRefundRepository; +import wisoft.nextframe.payment.infra.refund.RefundMapper; @Component +@RequiredArgsConstructor public class PaymentMapper implements EntityMapper { + + private final JpaRefundRepository jpaRefundRepository; + private final RefundMapper refundMapper; + @Override public Payment toDomain(PaymentEntity entity) { + Refund refund = jpaRefundRepository.findByPaymentId(entity.getId()) + .map(refundMapper::toDomain) + .orElse(null); + return Payment.reconstruct( PaymentId.of(entity.getId()), ReservationId.of(entity.getReservationId()), Money.of(entity.getTotalAmount()), entity.getRequestedAt(), entity.getStatus(), - null // 환불 이력은 아직 구현되지 않음 + refund ); } diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapter.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapter.java new file mode 100644 index 00000000..e34f04aa --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapter.java @@ -0,0 +1,136 @@ +package wisoft.nextframe.payment.infra.payment.adapter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; +import org.springframework.http.MediaType; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.github.resilience4j.circuitbreaker.CallNotPermittedException; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.extern.slf4j.Slf4j; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayExternalCallFailedException; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayTemporarilyUnavailableException; +import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; + +@Slf4j +@Component +@Profile("prod") +public class HttpPaymentGatewayAdapter implements PaymentGateway { + + private final RestClient restClient; + + public HttpPaymentGatewayAdapter(RestClient.Builder builder, @Value("${payment-gateway.url}") String baseUrl) { + var factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(3_000); + factory.setReadTimeout(5_000); + + this.restClient = builder + .baseUrl(baseUrl) + .requestFactory(factory) + .build(); + } + + @Override + @CircuitBreaker(name = "paymentGateway", fallbackMethod = "confirmPaymentFallback") + public PaymentConfirmResult confirmPayment(String paymentKey, String orderId, int amount) { + String raw = restClient.post() + .uri("/payments/confirm?provider=toss") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(new ConfirmRequest(paymentKey, orderId, amount)) + .retrieve() + .body(String.class); + + log.info("gateway raw response = {}", raw); + + // JSON 정상일 때만 매핑 시도 + try { + ObjectMapper mapper = new ObjectMapper(); + ConfirmResponse response = mapper.readValue(raw, ConfirmResponse.class); + + return new PaymentConfirmResult( + response.isSuccess(), + response.totalAmount(), + response.errorCode(), + response.errorMessage() + ); + } catch (Exception e) { + log.error("Failed to parse response: {}", raw, e); + return new PaymentConfirmResult(false, 0, "PARSE_ERROR", raw); + } + } + + @Override + @CircuitBreaker(name = "paymentGateway", fallbackMethod = "cancelPaymentFallback") + public PaymentCancelResult cancelPayment(String orderId, int cancelAmount, String cancelReason) { + String raw = restClient.post() + .uri("/payments/cancel?provider=toss") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .body(new CancelRequest(orderId, cancelAmount, cancelReason)) + .retrieve() + .body(String.class); + + log.info("gateway cancel raw response = {}", raw); + + try { + ObjectMapper mapper = new ObjectMapper(); + CancelResponse response = mapper.readValue(raw, CancelResponse.class); + + return new PaymentCancelResult( + response.isSuccess(), + response.cancelAmount(), + response.transactionKey(), + response.errorCode(), + response.errorMessage() + ); + } catch (Exception e) { + log.error("Failed to parse cancel response: {}", raw, e); + return new PaymentCancelResult(false, 0, null, "PARSE_ERROR", raw); + } + } + + private PaymentConfirmResult confirmPaymentFallback(String paymentKey, String orderId, int amount, Throwable e) { + if (e instanceof CallNotPermittedException) { + log.warn("결제 승인 차단됨 [CIRCUIT_BREAKER_OPEN]. orderId={}", orderId); + throw new PaymentGatewayTemporarilyUnavailableException("confirm", e); + } + + log.warn("결제 승인 외부 호출 실패 [PAYMENT_GATEWAY_EXTERNAL_CALL_FAILED]. orderId={}, error={}", + orderId, e.toString()); + throw new PaymentGatewayExternalCallFailedException("confirm", e); + } + + private PaymentCancelResult cancelPaymentFallback(String orderId, int cancelAmount, String cancelReason, + Throwable e) { + if (e instanceof CallNotPermittedException) { + log.warn("결제 취소 차단됨 [CIRCUIT_BREAKER_OPEN]. orderId={}", orderId); + throw new PaymentGatewayTemporarilyUnavailableException("cancel", e); + } + + log.warn("결제 취소 외부 호출 실패 [PAYMENT_GATEWAY_EXTERNAL_CALL_FAILED]. orderId={}, error={}", + orderId, e.toString()); + throw new PaymentGatewayExternalCallFailedException("cancel", e); + } + + public record ConfirmRequest(String paymentKey, String orderId, int amount) { + } + + public record ConfirmResponse(boolean isSuccess, int totalAmount, String errorCode, String errorMessage) { + } + + public record CancelRequest(String orderId, int cancelAmount, String cancelReason) { + } + + public record CancelResponse( + boolean isSuccess, + int cancelAmount, + String transactionKey, + String errorCode, + String errorMessage) { + } +} \ No newline at end of file diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptor.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapter.java similarity index 54% rename from payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptor.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapter.java index d338fb05..3a7534f2 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptor.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapter.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.infra.payment.adaptor; +package wisoft.nextframe.payment.infra.payment.adapter; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; @@ -6,11 +6,17 @@ @Component @Profile({"loadtest","dev"}) -public class StubPaymentGatewayAdaptor implements PaymentGateway { +public class StubPaymentGatewayAdapter implements PaymentGateway { @Override public PaymentConfirmResult confirmPayment(String paymentKey, String orderId, int amount) { // 테스트용 : 항상 성공하는 응답 반환 return new PaymentConfirmResult(true, amount, null, null); } + + @Override + public PaymentCancelResult cancelPayment(String orderId, int cancelAmount, String cancelReason) { + // 테스트용 : 항상 성공하는 응답 반환 + return new PaymentCancelResult(true, cancelAmount, "stub-tx-key", null, null); + } } diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketingAdaptor.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/TicketingAdaptor.java similarity index 76% rename from payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketingAdaptor.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/TicketingAdaptor.java index b5f24f7a..d99e2981 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketingAdaptor.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/TicketingAdaptor.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue; +package wisoft.nextframe.payment.infra.payment.adapter; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; @@ -9,14 +9,14 @@ import io.github.resilience4j.circuitbreaker.CallNotPermittedException; import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueExternalCallFailedException; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueInvalidResponseException; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueResult; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueTemporarilyUnavailableException; +import wisoft.nextframe.payment.application.payment.exception.TicketIssueExternalCallFailedException; +import wisoft.nextframe.payment.application.payment.exception.TicketIssueInvalidResponseException; +import wisoft.nextframe.payment.application.payment.exception.TicketIssueTemporarilyUnavailableException; +import wisoft.nextframe.payment.application.payment.port.output.TicketIssueResult; import wisoft.nextframe.payment.application.payment.port.output.TicketingClient; import wisoft.nextframe.payment.domain.ReservationId; -import wisoft.nextframe.payment.infra.payment.outbox.ticketissue.dto.TicketIssueRequest; -import wisoft.nextframe.payment.infra.payment.outbox.ticketissue.dto.TicketIssueResponse; +import wisoft.nextframe.payment.infra.payment.adapter.dto.TicketIssueRequest; +import wisoft.nextframe.payment.infra.payment.adapter.dto.TicketIssueResponse; /** * Ticketing 외부 서비스(SRT 서버)와 통신하는 어댑터 구현체. diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueRequest.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueRequest.java similarity index 53% rename from payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueRequest.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueRequest.java index 6a56dc33..2dbd9789 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueRequest.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueRequest.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue.dto; +package wisoft.nextframe.payment.infra.payment.adapter.dto; import java.util.UUID; diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueResponse.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueResponse.java similarity index 65% rename from payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueResponse.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueResponse.java index 61e65ba0..fd3aafec 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/dto/TicketIssueResponse.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adapter/dto/TicketIssueResponse.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue.dto; +package wisoft.nextframe.payment.infra.payment.adapter.dto; import java.util.UUID; diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/HttpPaymentGatewayAdaptor.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/HttpPaymentGatewayAdaptor.java deleted file mode 100644 index 8fb5ea13..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/adaptor/HttpPaymentGatewayAdaptor.java +++ /dev/null @@ -1,61 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.adaptor; - -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Profile; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestClient; - -import com.fasterxml.jackson.databind.ObjectMapper; - -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; - -@Slf4j -@Component -@Profile("prod") -public class HttpPaymentGatewayAdaptor implements PaymentGateway { - - private final RestClient restClient; - - public HttpPaymentGatewayAdaptor(RestClient.Builder builder, @Value("${payment-gateway.url}") String baseUrl) { - this.restClient = builder - .baseUrl(baseUrl) - .build(); - } - - @Override - public PaymentConfirmResult confirmPayment(String paymentKey, String orderId, int amount) { - String raw = restClient.post() - .uri("/payments/confirm?provider=toss") - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .body(new ConfirmRequest(paymentKey, orderId, amount)) - .retrieve() - .body(String.class); // 👈 응답을 무조건 String으로 받기 - - log.info("gateway raw response = {}", raw); - - // JSON 정상일 때만 매핑 시도 - try { - ObjectMapper mapper = new ObjectMapper(); - ConfirmResponse response = mapper.readValue(raw, ConfirmResponse.class); - - return new PaymentConfirmResult( - response.isSuccess(), - response.totalAmount(), - response.errorCode(), - response.errorMessage() - ); - } catch (Exception e) { - log.error("Failed to parse response: {}", raw, e); - return new PaymentConfirmResult(false, 0, "PARSE_ERROR", raw); - } - } - - public record ConfirmRequest(String paymentKey, String orderId, int amount) { - } - - public record ConfirmResponse(boolean isSuccess, int totalAmount, String errorCode, String errorMessage) { - } -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/JpaTicketIssueOutboxRepository.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/JpaTicketIssueOutboxRepository.java deleted file mode 100644 index 8d2cf3f5..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/JpaTicketIssueOutboxRepository.java +++ /dev/null @@ -1,30 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -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; - -public interface JpaTicketIssueOutboxRepository extends JpaRepository { - - Optional findByReservationId(UUID reservationId); - - @Query(""" - select o - from TicketIssueOutboxEntity o - where o.status = 'PENDING' - and o.nextRetryAt <= :now - order by o.createdAt asc - """) - List findReadyToRetry( - @Param("now") LocalDateTime now, - Pageable pageable - ); - - long countByStatus(String status); -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxEntity.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxEntity.java deleted file mode 100644 index 475f7711..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxEntity.java +++ /dev/null @@ -1,46 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue; - -import java.time.LocalDateTime; -import java.util.UUID; - -import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; - -@Getter -@Setter -@Entity -@Table(name = "ticket_issue_outbox") -public class TicketIssueOutboxEntity { - - @Id - @GeneratedValue - private UUID id; - - @Column(name = "payment_id", nullable = false) - private UUID paymentId; - - @Column(name = "reservation_id", nullable = false, unique = true) - private UUID reservationId; - - @Column(name = "ticket_id") - private UUID ticketId; - - @Column(nullable = false) - private String status; // PENDING, SUCCESS, FAILED - - @Column(name = "retry_count", nullable = false) - private int retryCount; - - @Column(name = "next_retry_at", nullable = false) - private LocalDateTime nextRetryAt; - - @Column(name = "last_error") - private String lastError; - - @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt; - - @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImpl.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImpl.java deleted file mode 100644 index 3f09d74d..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImpl.java +++ /dev/null @@ -1,121 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -import org.springframework.core.env.Environment; -import org.springframework.core.env.Profiles; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueOutboxRepository; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueOutboxTarget; - -@Slf4j -@Repository -@RequiredArgsConstructor -public class TicketIssueOutboxRepositoryImpl implements TicketIssueOutboxRepository { - - private final JpaTicketIssueOutboxRepository jpa; - private final Environment env; - - private boolean isCircuitBreakerTestProfile() { - return env.acceptsProfiles(Profiles.of("dev-cb-test")); - } - - @Override - @Transactional - public void upsertPending(UUID paymentId, UUID reservationId, String lastError, LocalDateTime now) { - log.debug("UPSERT_PENDING called paymentId={}, reservationId={}, now={}", paymentId, reservationId, now); - - var entity = jpa.findByReservationId(reservationId).orElseGet(() -> { - var e = new TicketIssueOutboxEntity(); - e.setPaymentId(paymentId); - e.setReservationId(reservationId); - e.setStatus("PENDING"); - e.setRetryCount(0); - e.setNextRetryAt(now); - e.setCreatedAt(now); - e.setUpdatedAt(now); - return e; - }); - - // 이미 SUCCESS면 굳이 덮어쓰지 않는 게 안전 - if ("SUCCESS".equals(entity.getStatus())) { - return; - } - - entity.setPaymentId(paymentId); - entity.setStatus("PENDING"); - entity.setLastError(lastError); - entity.setUpdatedAt(now); - - // 첫 적재면 now, 재적재면 즉시 재시도 대신 조금 뒤로 보내도 됨 - if (entity.getRetryCount() == 0) - entity.setNextRetryAt(now); - - jpa.save(entity); - } - - @Override - @Transactional - public void markSuccess(UUID reservationId, UUID ticketId, LocalDateTime now) { - jpa.findByReservationId(reservationId).ifPresent(entity -> { - entity.setStatus("SUCCESS"); - entity.setTicketId(ticketId); - entity.setUpdatedAt(now); - jpa.save(entity); - }); - } - - @Override - public List findTargets(LocalDateTime now, int limit) { - return jpa.findReadyToRetry(now, PageRequest.of(0, limit)) - .stream() - .map(e -> new TicketIssueOutboxTarget( - e.getReservationId(), - e.getPaymentId() - )) - .toList(); - } - - @Override - @Transactional - public void failAndBackoff(UUID reservationId, String lastError, LocalDateTime now) { - jpa.findByReservationId(reservationId).ifPresent(entity -> { - int nextRetry = entity.getRetryCount() + 1; - entity.setRetryCount(nextRetry); - entity.setLastError(lastError); - entity.setUpdatedAt(now); - - // 재시도 테스트 용도 : dev-cb-test에서는 OPEN 테스트를 위해 즉시 재시도 가능하게 만든다 - if (isCircuitBreakerTestProfile()) { - entity.setNextRetryAt(now); - } else { - // 백오프: 5s, 30s, 2m, 10m, 이후 FAILED - if (nextRetry == 1) - entity.setNextRetryAt(now.plusSeconds(5)); - else if (nextRetry == 2) - entity.setNextRetryAt(now.plusSeconds(30)); - else if (nextRetry == 3) - entity.setNextRetryAt(now.plusMinutes(2)); - else if (nextRetry == 4) - entity.setNextRetryAt(now.plusMinutes(10)); - else { - entity.setStatus("FAILED"); - entity.setNextRetryAt(now); - } - } - jpa.save(entity); - }); - } - - @Override - public long countPending() { - return jpa.countByStatus("PENDING"); - } -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueScheduler.java b/payment/src/main/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueScheduler.java deleted file mode 100644 index 215fdff4..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueScheduler.java +++ /dev/null @@ -1,29 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.schedule; - -import org.springframework.context.annotation.Profile; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueRetryUseCase; - -@Slf4j -@Component -@RequiredArgsConstructor -@Profile("!test") -public class TicketIssueScheduler { - - private final TicketIssueRetryUseCase retryUseCase; - - @Scheduled(fixedDelayString = "${ticket.issue.retry.delay-ms}") - public void retryTicketIssue() { - if (!retryUseCase.hasPending()) { - log.debug("ticket issue retry skipped. no pending"); - return; - } - - log.debug("ticket issue retry start"); - retryUseCase.runOnce(); - } -} diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/refund/JpaRefundRepository.java b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/JpaRefundRepository.java new file mode 100644 index 00000000..bbaaad86 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/JpaRefundRepository.java @@ -0,0 +1,12 @@ +package wisoft.nextframe.payment.infra.refund; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface JpaRefundRepository extends JpaRepository { + Optional findByPaymentId(UUID paymentId); +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundEntity.java b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundEntity.java similarity index 95% rename from payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundEntity.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundEntity.java index bebfcef2..efa3f5a6 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundEntity.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundEntity.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.domain.refund; +package wisoft.nextframe.payment.infra.refund; import java.time.LocalDateTime; import java.util.UUID; diff --git a/payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundMapper.java b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundMapper.java similarity index 71% rename from payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundMapper.java rename to payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundMapper.java index e3d6e946..64395619 100644 --- a/payment/src/main/java/wisoft/nextframe/payment/domain/refund/RefundMapper.java +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundMapper.java @@ -1,10 +1,16 @@ -package wisoft.nextframe.payment.domain.refund; +package wisoft.nextframe.payment.infra.refund; import java.util.UUID; +import org.springframework.stereotype.Component; + import wisoft.nextframe.payment.common.Money; +import wisoft.nextframe.payment.domain.refund.Refund; +import wisoft.nextframe.payment.domain.refund.RefundId; +import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; +import wisoft.nextframe.payment.domain.refund.RefundStatus; -// 도메인에는 없고 엔티티에는 있는 필드가 있습니다. 추후 검토 필요 +@Component public class RefundMapper { public Refund toDomain(RefundEntity entity) { diff --git a/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImpl.java b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImpl.java new file mode 100644 index 00000000..b0d70fb5 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImpl.java @@ -0,0 +1,31 @@ +package wisoft.nextframe.payment.infra.refund; + +import java.util.Optional; +import java.util.UUID; + +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; +import wisoft.nextframe.payment.application.refund.port.output.RefundRepository; +import wisoft.nextframe.payment.domain.refund.Refund; + +@Repository +@RequiredArgsConstructor +public class RefundRepositoryImpl implements RefundRepository { + + private final JpaRefundRepository jpaRefundRepository; + private final RefundMapper refundMapper; + + @Override + public Refund save(Refund refund, UUID paymentId, String reason) { + RefundEntity entity = refundMapper.toEntity(refund, paymentId, reason); + RefundEntity saved = jpaRefundRepository.save(entity); + return refundMapper.toDomain(saved); + } + + @Override + public Optional findByPaymentId(UUID paymentId) { + return jpaRefundRepository.findByPaymentId(paymentId) + .map(refundMapper::toDomain); + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/RefundController.java b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/RefundController.java new file mode 100644 index 00000000..88a38377 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/RefundController.java @@ -0,0 +1,72 @@ +package wisoft.nextframe.payment.presentation.refund; + +import java.util.UUID; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import wisoft.nextframe.payment.application.refund.RefundCancelFailedException; +import wisoft.nextframe.payment.application.refund.RefundService; +import wisoft.nextframe.payment.common.response.ApiResponse; +import wisoft.nextframe.payment.domain.payment.PaymentNotFoundException; +import wisoft.nextframe.payment.domain.payment.exception.RefundAlreadyExistsException; +import wisoft.nextframe.payment.domain.refund.Refund; +import wisoft.nextframe.payment.domain.refund.exception.NotRefundableException; +import wisoft.nextframe.payment.domain.refund.exception.RefundException; +import wisoft.nextframe.payment.presentation.refund.dto.RefundApprovedData; +import wisoft.nextframe.payment.presentation.refund.dto.RefundRequest; + +@Slf4j +@RestController +@RequestMapping("/api/v1/payments") +@RequiredArgsConstructor +public class RefundController { + + private final RefundService refundService; + + @PostMapping("/{paymentId}/refund") + public ResponseEntity> refund( + @PathVariable UUID paymentId, + @RequestBody RefundRequest request + ) { + try { + Refund refund = refundService.refund(paymentId, request.reason()); + RefundApprovedData refundData = new RefundApprovedData( + refund.getRefundId().getValue(), + refund.getRefundedAmount().getValue().intValue(), + refund.getPolicyStatus().name(), + refund.getStatus().name() + ); + return ResponseEntity.ok(ApiResponse.success(refundData)); + } catch (PaymentNotFoundException e) { + return ResponseEntity.badRequest().body( + ApiResponse.failed("존재하지 않는 결제입니다.") + ); + } catch (RefundAlreadyExistsException e) { + return ResponseEntity.badRequest().body( + ApiResponse.failed("이미 환불된 결제입니다.") + ); + } catch (NotRefundableException e) { + return ResponseEntity.badRequest().body( + ApiResponse.failed(e.getMessage()) + ); + } catch (RefundException e) { + return ResponseEntity.badRequest().body( + ApiResponse.failed(e.getMessage()) + ); + } catch (RefundCancelFailedException e) { + return ResponseEntity.badRequest().body( + ApiResponse.failed("결제사 환불 처리에 실패했습니다. 잠시 후 다시 시도해주세요.") + ); + } catch (Exception e) { + log.error("환불 처리 에러", e); + throw e; + } + } +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundApprovedData.java b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundApprovedData.java new file mode 100644 index 00000000..244390c3 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundApprovedData.java @@ -0,0 +1,11 @@ +package wisoft.nextframe.payment.presentation.refund.dto; + +import java.util.UUID; + +public record RefundApprovedData( + UUID refundId, + int refundAmount, + String refundPolicy, + String status +) { +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundRequest.java b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundRequest.java new file mode 100644 index 00000000..7b3a4e63 --- /dev/null +++ b/payment/src/main/java/wisoft/nextframe/payment/presentation/refund/dto/RefundRequest.java @@ -0,0 +1,6 @@ +package wisoft.nextframe.payment.presentation.refund.dto; + +public record RefundRequest( + String reason +) { +} diff --git a/payment/src/main/java/wisoft/nextframe/payment/presentation/ticketissue/TicketIssueRetryAdminController.java b/payment/src/main/java/wisoft/nextframe/payment/presentation/ticketissue/TicketIssueRetryAdminController.java deleted file mode 100644 index 24b9b1bb..00000000 --- a/payment/src/main/java/wisoft/nextframe/payment/presentation/ticketissue/TicketIssueRetryAdminController.java +++ /dev/null @@ -1,24 +0,0 @@ -package wisoft.nextframe.payment.presentation.ticketissue; - -import org.springframework.context.annotation.Profile; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RestController; - -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueRetryUseCase; - -@Slf4j -@RestController -@RequiredArgsConstructor -@Profile("dev") -public class TicketIssueRetryAdminController { - - private final TicketIssueRetryUseCase retryUseCase; - - @PostMapping("/admin/ticket-issue-retry/run-once") - public void runOnce() { - log.info("manual runOnce called"); - retryUseCase.runOnce(); - } -} \ No newline at end of file diff --git a/payment/src/test/java/wisoft/nextframe/payment/application/RefundServiceTest.java b/payment/src/test/java/wisoft/nextframe/payment/application/RefundServiceTest.java deleted file mode 100644 index ab98d15a..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/application/RefundServiceTest.java +++ /dev/null @@ -1,24 +0,0 @@ -package wisoft.nextframe.payment.application; - -import static org.assertj.core.api.Assertions.*; -import static wisoft.nextframe.payment.domain.fixture.TestRefundFactory.*; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import wisoft.nextframe.payment.domain.refund.Refund; - -public class RefundServiceTest { - - @Test - @DisplayName("거절 상태에서 승인을 시도하면 예외가 발생한다") - void cannotApproveAfterRejected() { - Refund refund = refundDeny(); - refund.reject(); - - assertThatThrownBy(refund::approve) - .isInstanceOf(RuntimeException.class) - .hasMessageContaining("승인은 REQUESTED 상태에서만 가능합니다."); - } - -} diff --git a/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentEventHandlerTest.java b/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentEventHandlerTest.java index af653d8e..7323eec9 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentEventHandlerTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentEventHandlerTest.java @@ -12,31 +12,30 @@ import org.mockito.junit.jupiter.MockitoExtension; import wisoft.nextframe.payment.application.payment.handler.PaymentEventHandler; -import wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueOutboxService; -import wisoft.nextframe.payment.domain.payment.event.PaymentApprovedEvent; +import wisoft.nextframe.payment.application.payment.outbox.cancel.ReservationCancelOutboxService; +import wisoft.nextframe.payment.domain.payment.event.PaymentFailedEvent; @ExtendWith(MockitoExtension.class) class PaymentEventHandlerTest { @Mock - TicketIssueOutboxService outboxService; + ReservationCancelOutboxService reservationCancelOutboxService; @InjectMocks PaymentEventHandler handler; @Test - @DisplayName("이벤트를 받으면 outboxService 호출") - void paymentApprovedEventTriggersTicketIssue() { + @DisplayName("결제 실패 이벤트를 받으면 예약 취소 outbox 서비스를 호출한다") + void paymentFailedEventTriggersReservationCancel() { // given UUID paymentId = UUID.randomUUID(); UUID reservationId = UUID.randomUUID(); - PaymentApprovedEvent event = - new PaymentApprovedEvent(paymentId, reservationId); + PaymentFailedEvent event = new PaymentFailedEvent(paymentId, reservationId); // when - handler.onPaymentApproved(event); + handler.onPaymentFailed(event); // then - verify(outboxService).issueOrEnqueue(paymentId, reservationId); + verify(reservationCancelOutboxService).cancelOrEnqueue(paymentId, reservationId); } } diff --git a/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentServiceIntegrationTest.java b/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentServiceIntegrationTest.java index 4f97de36..9887143c 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentServiceIntegrationTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/application/payment/PaymentServiceIntegrationTest.java @@ -10,17 +10,17 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; import wisoft.nextframe.payment.application.payment.port.output.PaymentRepository; import wisoft.nextframe.payment.application.payment.port.output.ReservationReader; +import wisoft.nextframe.payment.application.payment.port.output.TicketIssueResult; import wisoft.nextframe.payment.application.payment.port.output.TicketingClient; import wisoft.nextframe.payment.domain.ReservationId; import wisoft.nextframe.payment.domain.payment.Payment; +import wisoft.nextframe.payment.domain.payment.PaymentStatus; +import wisoft.nextframe.payment.domain.payment.exception.PaymentConfirmedFailedException; import wisoft.nextframe.payment.infra.config.AbstractIntegrationTest; -import wisoft.nextframe.payment.infra.payment.outbox.ticketissue.JpaTicketIssueOutboxRepository; import wisoft.nextframe.payment.presentation.payment.dto.PaymentConfirmRequest; public class PaymentServiceIntegrationTest extends AbstractIntegrationTest { @@ -40,10 +40,6 @@ public class PaymentServiceIntegrationTest extends AbstractIntegrationTest { @MockitoBean private ReservationReader reservationReader; - // outbox DB 상태를 직접 확인하기 위한 용도 - @Autowired - private JpaTicketIssueOutboxRepository jpaOutboxRepository; - @BeforeEach void setUp() { given(reservationReader.exists(any(ReservationId.class))).willReturn(true); @@ -61,6 +57,9 @@ void confirmPaymentSuccess() { null // errorMessage )); + given(ticketingClient.issueTicket(any(ReservationId.class))) + .willReturn(new TicketIssueResult(UUID.randomUUID())); + PaymentConfirmRequest request = new PaymentConfirmRequest( "test_payment_key", UUID.randomUUID().toString(), @@ -73,13 +72,11 @@ void confirmPaymentSuccess() { //then assertThat(payment.isSucceeded()).isTrue(); assertThat(paymentRepository.findById(payment.getId())).isPresent(); - } - @Transactional(propagation = Propagation.NOT_SUPPORTED) @Test - @DisplayName("티켓 서버 호출이 실패하면 outbox에 PENDING이 적재된다.") - void enqueueOutboxWhenTicketIssueFails() { + @DisplayName("티켓 발급 실패 시 PG 취소 후 FAILED 상태로 저장된다") + void failPaymentWhenTicketIssueFails() { // given: PG 승인 성공 given(paymentGateway.confirmPayment(anyString(), anyString(), anyInt())) .willReturn(new PaymentGateway.PaymentConfirmResult( @@ -93,6 +90,12 @@ void enqueueOutboxWhenTicketIssueFails() { given(ticketingClient.issueTicket(any(ReservationId.class))) .willThrow(new RuntimeException("ticket server down")); + // given: PG 취소 성공 + given(paymentGateway.cancelPayment(anyString(), anyInt(), anyString())) + .willReturn(new PaymentGateway.PaymentCancelResult( + true, 10000, "txn-key", null, null + )); + UUID reservationId = UUID.randomUUID(); PaymentConfirmRequest request = new PaymentConfirmRequest( "test_payment_key", @@ -100,18 +103,19 @@ void enqueueOutboxWhenTicketIssueFails() { 10000 ); - // when - paymentService.confirmPayment(request); + // when & then: 티켓 발급 실패로 인해 PaymentConfirmedFailedException 발생 + assertThatThrownBy(() -> paymentService.confirmPayment(request)) + .isInstanceOf(PaymentConfirmedFailedException.class); - // 1) 여기서 먼저 확인: 핸들러가 티켓 호출을 실제로 했는지 - then(ticketingClient).should(times(1)).issueTicket(any(ReservationId.class)); + // then: 티켓 발급이 호출되었는지 확인 then(ticketingClient).should().issueTicket(eq(ReservationId.of(reservationId))); - // 2) outbox가 정말 쌓였는지 확인 (없으면 원인 좁혀짐) - var all = jpaOutboxRepository.findAll(); - assertThat(all).isNotEmpty(); + // then: PG 취소가 호출되었는지 확인 + then(paymentGateway).should().cancelPayment(eq(reservationId.toString()), eq(10000), eq("티켓 발급 실패")); - var outbox = jpaOutboxRepository.findByReservationId(reservationId).orElseThrow(); - assertThat(outbox.getStatus()).isEqualTo("PENDING"); + // then: Payment가 FAILED 상태로 DB에 저장됨 + var saved = paymentRepository.findByReservationId(ReservationId.of(reservationId)); + assertThat(saved).isPresent(); + assertThat(saved.get().getStatus()).isEqualTo(PaymentStatus.FAILED); } } diff --git a/payment/src/test/java/wisoft/nextframe/payment/application/refund/RefundTransactionServiceTest.java b/payment/src/test/java/wisoft/nextframe/payment/application/refund/RefundTransactionServiceTest.java new file mode 100644 index 00000000..fa732d9c --- /dev/null +++ b/payment/src/test/java/wisoft/nextframe/payment/application/refund/RefundTransactionServiceTest.java @@ -0,0 +1,168 @@ +package wisoft.nextframe.payment.application.refund; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +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.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import wisoft.nextframe.payment.application.payment.port.output.PaymentRepository; +import wisoft.nextframe.payment.application.payment.port.output.ReservationReader; +import wisoft.nextframe.payment.application.refund.RefundTransactionService.RefundPrepareResult; +import wisoft.nextframe.payment.application.refund.port.output.RefundRepository; +import wisoft.nextframe.payment.common.Money; +import wisoft.nextframe.payment.domain.ReservationId; +import wisoft.nextframe.payment.domain.payment.Payment; +import wisoft.nextframe.payment.domain.payment.PaymentId; +import wisoft.nextframe.payment.domain.payment.PaymentIssuer; +import wisoft.nextframe.payment.domain.payment.PaymentNotFoundException; +import wisoft.nextframe.payment.domain.payment.PaymentStatus; +import wisoft.nextframe.payment.domain.refund.Refund; +import wisoft.nextframe.payment.domain.refund.RefundId; +import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; +import wisoft.nextframe.payment.domain.refund.RefundStatus; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RefundTransactionService 단위 테스트") +class RefundTransactionServiceTest { + + @Mock + PaymentRepository paymentRepository; + + @Mock + RefundRepository refundRepository; + + @Mock + ReservationReader reservationReader; + + private final PaymentIssuer paymentIssuer = new PaymentIssuer(); + + private RefundTransactionService refundTransactionService; + + private static final UUID PAYMENT_ID = UUID.randomUUID(); + private static final ReservationId RESERVATION_ID = ReservationId.of(UUID.randomUUID()); + private static final LocalDateTime PERFORMANCE_START = LocalDateTime.now().plusDays(10); + private static final String REASON = "개인 사정"; + + private Payment succeededPayment; + + @BeforeEach + void setUp() { + refundTransactionService = new RefundTransactionService( + paymentRepository, refundRepository, reservationReader, paymentIssuer + ); + + succeededPayment = Payment.reconstruct( + PaymentId.of(PAYMENT_ID), + RESERVATION_ID, + Money.of(10_000), + LocalDateTime.now(), + PaymentStatus.SUCCEEDED, + null + ); + } + + @Nested + @DisplayName("prepareRefund") + class PrepareRefund { + + @Test + @DisplayName("정상적인 환불 준비 시 Refund가 생성된다") + void prepareRefund_success() { + // given + given(paymentRepository.findById(any(PaymentId.class))) + .willReturn(Optional.of(succeededPayment)); + given(refundRepository.findByPaymentId(PAYMENT_ID)) + .willReturn(Optional.empty()); + given(reservationReader.getPerformanceDateTime(any(ReservationId.class))) + .willReturn(PERFORMANCE_START); + + // when + RefundPrepareResult result = refundTransactionService.prepareRefund(PAYMENT_ID); + + // then + assertThat(result.alreadyRefunded()).isFalse(); + assertThat(result.payment()).isNotNull(); + assertThat(result.refund()).isNotNull(); + assertThat(result.refund().getStatus()).isEqualTo(RefundStatus.REQUESTED); + } + + @Test + @DisplayName("존재하지 않는 결제에 대한 환불 준비 시 예외가 발생한다") + void prepareRefund_paymentNotFound_throwsException() { + // given + given(paymentRepository.findById(any(PaymentId.class))) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> refundTransactionService.prepareRefund(PAYMENT_ID)) + .isInstanceOf(PaymentNotFoundException.class); + } + + @Test + @DisplayName("이미 환불된 결제에 대한 준비 시 기존 환불을 반환한다") + void prepareRefund_alreadyRefunded_returnsExisting() { + // given + Refund existingRefund = Refund.reconstruct( + RefundId.generate(), + Money.of(10_000), + RefundStatus.APPROVED, + RefundPolicyStatus.REFUND_FULL, + LocalDateTime.now(), + null + ); + + given(paymentRepository.findById(any(PaymentId.class))) + .willReturn(Optional.of(succeededPayment)); + given(refundRepository.findByPaymentId(PAYMENT_ID)) + .willReturn(Optional.of(existingRefund)); + + // when + RefundPrepareResult result = refundTransactionService.prepareRefund(PAYMENT_ID); + + // then + assertThat(result.alreadyRefunded()).isTrue(); + assertThat(result.refund()).isSameAs(existingRefund); + } + } + + @Nested + @DisplayName("completeRefund") + class CompleteRefund { + + @Test + @DisplayName("환불 완료 시 승인 처리되고 저장된다") + void completeRefund_success() { + // given + given(paymentRepository.findById(any(PaymentId.class))) + .willReturn(Optional.of(succeededPayment)); + given(refundRepository.save(any(Refund.class), eq(PAYMENT_ID), eq(REASON))) + .willAnswer(inv -> inv.getArgument(0)); + given(paymentRepository.save(any(Payment.class))) + .willAnswer(inv -> inv.getArgument(0)); + + Refund refund = Refund.issue( + LocalDateTime.now(), + PERFORMANCE_START, + Money.of(10_000) + ); + + // when + Refund result = refundTransactionService.completeRefund(PAYMENT_ID, refund, REASON); + + // then + assertThat(result.getStatus()).isEqualTo(RefundStatus.APPROVED); + then(refundRepository).should().save(any(Refund.class), eq(PAYMENT_ID), eq(REASON)); + then(paymentRepository).should().save(any(Payment.class)); + } + } +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/domain/fixture/RefundEntityFixture.java b/payment/src/test/java/wisoft/nextframe/payment/domain/fixture/RefundEntityFixture.java deleted file mode 100644 index e064ca61..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/domain/fixture/RefundEntityFixture.java +++ /dev/null @@ -1,47 +0,0 @@ -package wisoft.nextframe.payment.domain.fixture; - -import java.time.LocalDateTime; -import java.util.UUID; - -import wisoft.nextframe.payment.common.Money; -import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundEntity; -import wisoft.nextframe.payment.domain.refund.RefundId; -import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; -import wisoft.nextframe.payment.domain.refund.RefundStatus; - -public class RefundEntityFixture { - - public static final UUID DEFAULT_REFUND_ID = UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - public static final UUID DEFAULT_PAYMENT_ID = UUID.fromString("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); - public static final int DEFAULT_REFUND_AMOUNT = 12000; - public static final String DEFAULT_STATUS = "REQUESTED"; - public static final String DEFAULT_POLICY = "REFUND_80_PERCENT"; - public static final String DEFAULT_REASON = "관람 불가 사유"; - public static final LocalDateTime DEFAULT_REQUESTED_AT = LocalDateTime.of(2025, 7, 31, 12, 0); - public static final LocalDateTime DEFAULT_COMPLETED_AT = LocalDateTime.of(2025, 8, 1, 9, 30); - - public static RefundEntity sampleEntity() { - return RefundEntity.builder() - .id(DEFAULT_REFUND_ID) - .paymentId(DEFAULT_PAYMENT_ID) - .refundAmount(DEFAULT_REFUND_AMOUNT) - .status(DEFAULT_STATUS) - .refundPolicy(DEFAULT_POLICY) - .reason(DEFAULT_REASON) - .requestedAt(DEFAULT_REQUESTED_AT) - .completedAt(DEFAULT_COMPLETED_AT) - .build(); - } - - public static Refund sampleDomain() { - return Refund.reconstruct( - RefundId.of(DEFAULT_REFUND_ID), - Money.of(DEFAULT_REFUND_AMOUNT), - RefundStatus.valueOf(DEFAULT_STATUS), - RefundPolicyStatus.valueOf(DEFAULT_POLICY), - DEFAULT_REQUESTED_AT, - DEFAULT_COMPLETED_AT - ); - } -} diff --git a/payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentIssuerTest.java b/payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentIssuerTest.java new file mode 100644 index 00000000..49ad2fc9 --- /dev/null +++ b/payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentIssuerTest.java @@ -0,0 +1,90 @@ +package wisoft.nextframe.payment.domain.payment; + +import static org.assertj.core.api.Assertions.*; +import static wisoft.nextframe.payment.domain.fixture.TestPaymentFactory.*; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import wisoft.nextframe.payment.domain.payment.exception.InvalidPaymentStatusException; +import wisoft.nextframe.payment.domain.payment.exception.RefundAlreadyExistsException; +import wisoft.nextframe.payment.domain.refund.Refund; +import wisoft.nextframe.payment.domain.refund.RefundStatus; +import wisoft.nextframe.payment.domain.refund.exception.NotRefundableException; + +@DisplayName("PaymentIssuer 도메인 서비스 테스트") +class PaymentIssuerTest { + + private final PaymentIssuer paymentIssuer = new PaymentIssuer(); + + private static final LocalDateTime PERFORMANCE_START = LocalDateTime.of(2025, 8, 10, 19, 0); + + @Test + @DisplayName("승인된 결제에 대해 환불을 발급하면 REQUESTED 상태의 Refund가 생성된다") + void issueRefund_succeededPayment_createsRefund() { + // given + Payment payment = succeeded(); + LocalDateTime requestAt = PERFORMANCE_START.minusDays(8); + + // when + Refund refund = paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START); + + // then + assertThat(refund.getStatus()).isEqualTo(RefundStatus.REQUESTED); + assertThat(refund.getRefundedAmount().getValue()).isPositive(); + assertThat(payment.hasRefunded()).isTrue(); + } + + @Test + @DisplayName("결제가 승인 상태가 아니면 환불 발급 시 예외가 발생한다") + void issueRefund_notSucceededPayment_throwsException() { + // given + Payment payment = requested(); + LocalDateTime requestAt = PERFORMANCE_START.minusDays(8); + + // when & then + assertThatThrownBy(() -> paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START)) + .isInstanceOf(InvalidPaymentStatusException.class); + } + + @Test + @DisplayName("이미 환불된 결제에 대해 환불 발급 시 예외가 발생한다") + void issueRefund_alreadyRefunded_throwsException() { + // given + Payment payment = succeeded(); + LocalDateTime requestAt = PERFORMANCE_START.minusDays(8); + paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START); + + // when & then + assertThatThrownBy(() -> paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START)) + .isInstanceOf(RefundAlreadyExistsException.class); + } + + @Test + @DisplayName("환불 불가 기간이면 예외가 발생한다") + void issueRefund_nonRefundablePeriod_throwsException() { + // given + Payment payment = succeeded(); + LocalDateTime requestAt = PERFORMANCE_START.minusMinutes(30); + + // when & then + assertThatThrownBy(() -> paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START)) + .isInstanceOf(NotRefundableException.class); + } + + @Test + @DisplayName("환불 발급 후 Payment에 Refund가 할당된다") + void issueRefund_assignsRefundToPayment() { + // given + Payment payment = succeeded(); + LocalDateTime requestAt = PERFORMANCE_START.minusDays(4); + + // when + Refund refund = paymentIssuer.issueRefund(payment, requestAt, PERFORMANCE_START); + + // then + assertThat(payment.getCurrentRefund()).isSameAs(refund); + } +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/domain/PaymentTest.java b/payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentTest.java similarity index 86% rename from payment/src/test/java/wisoft/nextframe/payment/domain/PaymentTest.java rename to payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentTest.java index 375a1100..5ffc8462 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/domain/PaymentTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/domain/payment/PaymentTest.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.domain; +package wisoft.nextframe.payment.domain.payment; import static org.assertj.core.api.Assertions.*; import static wisoft.nextframe.payment.domain.fixture.TestPaymentFactory.*; @@ -10,9 +10,6 @@ import org.junit.jupiter.api.Test; import wisoft.nextframe.payment.common.Money; -import wisoft.nextframe.payment.domain.payment.Payment; -import wisoft.nextframe.payment.domain.payment.PaymentStatus; -import wisoft.nextframe.payment.domain.payment.event.PaymentApprovedEvent; import wisoft.nextframe.payment.domain.payment.exception.InvalidPaymentStatusException; import wisoft.nextframe.payment.domain.payment.exception.MissingReservationException; import wisoft.nextframe.payment.domain.payment.exception.PaymentAlreadySucceededException; @@ -80,17 +77,12 @@ void denySucceed_alreadySucceededOrPaid() { } @Test - @DisplayName("승인 시 PaymentApprovedEvent가 발행된다") - void approvePayment_eventPublished() { + @DisplayName("승인 시 도메인 이벤트가 발행되지 않는다") + void approvePayment_noEventPublished() { Payment payment = requested(); payment.approve(); - assertThat(payment.getDomainEvents()) - .hasSize(1) - .first() - .isInstanceOf(PaymentApprovedEvent.class) - .extracting("paymentId", "reservationId") - .containsExactly(payment.getId().getValue(), payment.getReservationId().value()); + assertThat(payment.getDomainEvents()).isEmpty(); } @Test @@ -128,7 +120,7 @@ void failPayment_failStatus() { @DisplayName("clearDomainEvents() 호출 시 이벤트 리스트가 비워진다") void clearEvents() { Payment payment = requested(); - payment.approve(); + payment.fail(); assertThat(payment.getDomainEvents()).isNotEmpty(); payment.clearDomainEvents(); diff --git a/payment/src/test/java/wisoft/nextframe/payment/domain/RefundPolicyStatusTest.java b/payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundPolicyStatusTest.java similarity index 93% rename from payment/src/test/java/wisoft/nextframe/payment/domain/RefundPolicyStatusTest.java rename to payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundPolicyStatusTest.java index d183483d..8d589cd0 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/domain/RefundPolicyStatusTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundPolicyStatusTest.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.domain; +package wisoft.nextframe.payment.domain.refund; import static org.assertj.core.api.Assertions.*; import static wisoft.nextframe.payment.domain.fixture.TestRefundFactory.*; @@ -9,8 +9,6 @@ import org.junit.jupiter.api.Test; import wisoft.nextframe.payment.common.Money; -import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; public class RefundPolicyStatusTest { @@ -63,14 +61,11 @@ void refundDeny_between1HAnd24H() { @Test @DisplayName("공연 시작 1시간 이내에는 환불 시도 시 예외가 발생한다") void issueDeny_within1Hour() { - Refund refund = refundDeny(); assertThatThrownBy(() -> { refund.reject(); refund.validateRefundable(); }).isInstanceOf(RuntimeException.class) .hasMessageContaining("공연 시작 1시간 전에는 환불할 수 없습니다."); - } - } diff --git a/payment/src/test/java/wisoft/nextframe/payment/domain/RefundTest.java b/payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundTest.java similarity index 84% rename from payment/src/test/java/wisoft/nextframe/payment/domain/RefundTest.java rename to payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundTest.java index 19a47e41..6350ddb0 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/domain/RefundTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/domain/refund/RefundTest.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.domain; +package wisoft.nextframe.payment.domain.refund; import static org.assertj.core.api.Assertions.*; import static wisoft.nextframe.payment.domain.fixture.TestRefundFactory.*; @@ -6,10 +6,6 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; -import wisoft.nextframe.payment.domain.refund.RefundStatus; - public class RefundTest { @Test @@ -17,7 +13,6 @@ public class RefundTest { void initialRequestedStatus() { Refund refund = requested(); - // then assertThat(refund.getStatus()).isEqualTo(RefundStatus.REQUESTED); } @@ -28,17 +23,14 @@ void approveIssue_changesStatusToApproved() { refund.approve(); - // then assertThat(refund.getStatus()).isEqualTo(RefundStatus.APPROVED); } @Test @DisplayName("환불 거절 시 상태는 REJECTED가 된다") void rejectIssue_changesStatusToRejected() { - Refund refund = requested(); - refund.reject(); assertThat(refund.getStatus()).isEqualTo(RefundStatus.REJECTED); @@ -55,13 +47,23 @@ void cannotRejectAfterApproved() { .hasMessageContaining("거절은 REQUESTED 상태에서만 가능합니다."); } + @Test + @DisplayName("거절 상태에서 승인을 시도하면 예외가 발생한다") + void cannotApproveAfterRejected() { + Refund refund = refundDeny(); + refund.reject(); + + assertThatThrownBy(refund::approve) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("승인은 REQUESTED 상태에서만 가능합니다."); + } + @Test @DisplayName("결제 완료된 사용자에 대해 환불 요청 시 정책에 따라 환불 상태가 결정된다:전액환불정책경우") void refundApproved_whenPaidAndFullyIssue() { Refund refund = refundFull(); refund.approve(); - // then assertThat(refund.getStatus()).isEqualTo(RefundStatus.APPROVED); assertThat(refund.getPolicyStatus()).isEqualTo(RefundPolicyStatus.REFUND_FULL); } @@ -69,16 +71,11 @@ void refundApproved_whenPaidAndFullyIssue() { @Test @DisplayName("결제 완료된 사용자에 대해 환불 요청 시 정책과 금액에 따라 환불이 승인된다") void approveIssueWithCorrectAmountAndPolicy() { - - // when Refund refund = refund60percent(); refund.approve(); - // then assertThat(refund.getStatus()).isEqualTo(RefundStatus.APPROVED); assertThat(refund.getPolicyStatus()).isEqualTo(RefundPolicyStatus.REFUND_60_PERCENT); assertThat(refund.getRefundedAmount().getValue()).isEqualByComparingTo("6000"); - } - -} \ No newline at end of file +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/config/AbstractIntegrationTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/config/AbstractIntegrationTest.java index 2ff3c0e8..692b68b4 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/config/AbstractIntegrationTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/config/AbstractIntegrationTest.java @@ -22,7 +22,7 @@ @Transactional @ActiveProfiles("test") @TestInstance(TestInstance.Lifecycle.PER_CLASS) -public abstract class AbstractIntegrationTest implements PostgresSQLContainerInitializer { +public abstract class AbstractIntegrationTest extends PostgresSQLContainerInitializer { /** * Testcontainers에 의해 동적으로 시작된 PostgreSQL 컨테이너 접속 정보를 @@ -35,7 +35,7 @@ public abstract class AbstractIntegrationTest implements PostgresSQLContainerIni static void configureProperties(DynamicPropertyRegistry registry) { // 인터페이스에 정의된 컨테이너 인스턴스(POSTGRES_CONTAINER)를 참조합니다. - registry.add("spring.datasource.url", POSTGRES_CONTAINER::getJdbcUrl); + registry.add("spring.datasource.url", () -> getContainer().getJdbcUrl()); registry.add("spring.datasource.username", POSTGRES_CONTAINER::getUsername); registry.add("spring.datasource.password", POSTGRES_CONTAINER::getPassword); registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver"); diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/config/PostgresSQLContainerInitializer.java b/payment/src/test/java/wisoft/nextframe/payment/infra/config/PostgresSQLContainerInitializer.java index c62030c1..a598c2c1 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/config/PostgresSQLContainerInitializer.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/config/PostgresSQLContainerInitializer.java @@ -6,25 +6,27 @@ * PostgreSQL 컨테이너 초기화 인터페이스 * 전체 테스트 병렬 실행을 위해 컨테이너를 단 한 번만 초기화 합니다. */ -public interface PostgresSQLContainerInitializer { +public class PostgresSQLContainerInitializer { - PostgreSQLContainer POSTGRES_CONTAINER = startContainer(); + static { + System.setProperty("api.version", "1.44"); + } /** * PostgreSQL 컨테이너를 생성하고 실행시키는 메서드입니다. + * * @return PostgreSQLContainer 인스턴스 */ - private static PostgreSQLContainer startContainer() { - // 컨테이너의 인스턴스를 생성합니다. - PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:15") + + static final PostgreSQLContainer POSTGRES_CONTAINER = + new PostgreSQLContainer<>("postgres:15") .withDatabaseName("testdb") .withUsername("testuser") .withPassword("testpass"); - // 컨테이너를 시작합니다. - postgres.start(); - - // 시작된 컨테이너 인스턴스를 반환합니다. - return postgres; + static PostgreSQLContainer getContainer() { + if (!POSTGRES_CONTAINER.isRunning()) + POSTGRES_CONTAINER.start(); + return POSTGRES_CONTAINER; } } \ No newline at end of file diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterCircuitBreakerTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterCircuitBreakerTest.java new file mode 100644 index 00000000..a9a8ea53 --- /dev/null +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterCircuitBreakerTest.java @@ -0,0 +1,140 @@ +package wisoft.nextframe.payment.infra.payment.adapter; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.aop.AopAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.web.client.RestClient; + +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import io.github.resilience4j.springboot3.circuitbreaker.autoconfigure.CircuitBreakerAutoConfiguration; +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayExternalCallFailedException; +import wisoft.nextframe.payment.application.payment.exception.PaymentGatewayTemporarilyUnavailableException; +import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; + +@SpringBootTest(classes = HttpPaymentGatewayAdapterCircuitBreakerTest.TestConfig.class) +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class HttpPaymentGatewayAdapterCircuitBreakerTest { + + static final MockWebServer mockWebServer; + + static { + mockWebServer = new MockWebServer(); + try { + mockWebServer.start(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + @Configuration + @Import({AopAutoConfiguration.class, CircuitBreakerAutoConfiguration.class}) + static class TestConfig { + + @Bean + PaymentGateway paymentGateway() { + String baseUrl = mockWebServer.url("/").toString(); + return new HttpPaymentGatewayAdapter(RestClient.builder(), baseUrl); + } + } + + @Autowired + private PaymentGateway paymentGateway; + + @Autowired + private CircuitBreakerRegistry circuitBreakerRegistry; + + @DynamicPropertySource + static void overrideProperties(DynamicPropertyRegistry registry) { + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.slidingWindowType", () -> "COUNT_BASED"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.slidingWindowSize", () -> "5"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.minimumNumberOfCalls", () -> "3"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.failureRateThreshold", () -> "50"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.waitDurationInOpenState", () -> "60s"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.permittedNumberOfCallsInHalfOpenState", () -> "1"); + registry.add("resilience4j.circuitbreaker.instances.paymentGateway.recordExceptions[0]", + () -> "org.springframework.web.client.ResourceAccessException"); + } + + @AfterAll + static void stopServer() throws IOException { + mockWebServer.shutdown(); + } + + @BeforeEach + void resetCircuitBreaker() { + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway"); + cb.reset(); + } + + @Test + @DisplayName("서버 오류 시 CB fallback으로 ExternalCallFailedException이 발생한다") + void serverError_triggersFallback_externalCallFailed() { + mockWebServer.enqueue(new MockResponse().setResponseCode(500)); + + assertThatThrownBy(() -> paymentGateway.confirmPayment("key-1", "order-1", 10000)) + .isInstanceOf(PaymentGatewayExternalCallFailedException.class); + } + + @Test + @DisplayName("cancelPayment 서버 오류 시에도 ExternalCallFailedException이 발생한다") + void cancelServerError_triggersFallback() { + mockWebServer.enqueue(new MockResponse().setResponseCode(500)); + + assertThatThrownBy(() -> paymentGateway.cancelPayment("order-1", 5000, "테스트")) + .isInstanceOf(PaymentGatewayExternalCallFailedException.class); + } + + @Test + @DisplayName("CB가 OPEN 상태이면 confirmPayment에서 TemporarilyUnavailableException이 발생한다") + void circuitBreakerOpen_confirm_throwsTemporarilyUnavailable() { + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway"); + cb.transitionToOpenState(); + + assertThat(cb.getState()).isEqualTo(CircuitBreaker.State.OPEN); + + assertThatThrownBy(() -> paymentGateway.confirmPayment("key-1", "order-1", 10000)) + .isInstanceOf(PaymentGatewayTemporarilyUnavailableException.class); + } + + @Test + @DisplayName("CB가 OPEN 상태이면 cancelPayment에서 TemporarilyUnavailableException이 발생한다") + void circuitBreakerOpen_cancel_throwsTemporarilyUnavailable() { + CircuitBreaker cb = circuitBreakerRegistry.circuitBreaker("paymentGateway"); + cb.transitionToOpenState(); + + assertThatThrownBy(() -> paymentGateway.cancelPayment("order-1", 5000, "테스트")) + .isInstanceOf(PaymentGatewayTemporarilyUnavailableException.class); + } + + @Test + @DisplayName("CB가 CLOSED 상태이면 정상 응답을 반환한다") + void circuitBreakerClosed_returnsNormalResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody(""" + {"isSuccess": true, "totalAmount": 10000, "errorCode": null, "errorMessage": null} + """) + .setHeader("Content-Type", "application/json")); + + var result = paymentGateway.confirmPayment("key-1", "order-1", 10000); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.totalAmount()).isEqualTo(10000); + } +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterTest.java new file mode 100644 index 00000000..90c1263c --- /dev/null +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/HttpPaymentGatewayAdapterTest.java @@ -0,0 +1,184 @@ +package wisoft.nextframe.payment.infra.payment.adapter; + +import static org.assertj.core.api.Assertions.*; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +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.springframework.web.client.RestClient; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway.PaymentCancelResult; +import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway.PaymentConfirmResult; + +class HttpPaymentGatewayAdapterTest { + + private MockWebServer mockWebServer; + private HttpPaymentGatewayAdapter adapter; + + @BeforeEach + void setUp() throws IOException { + mockWebServer = new MockWebServer(); + mockWebServer.start(); + + String baseUrl = mockWebServer.url("/").toString(); + adapter = new HttpPaymentGatewayAdapter(RestClient.builder(), baseUrl); + } + + @AfterEach + void tearDown() throws IOException { + mockWebServer.shutdown(); + } + + @Nested + @DisplayName("결제 승인 (confirmPayment)") + class ConfirmPayment { + + @Test + @DisplayName("성공 응답을 정상적으로 파싱한다") + void successResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody(""" + {"isSuccess": true, "totalAmount": 10000, "errorCode": null, "errorMessage": null} + """) + .setHeader("Content-Type", "application/json")); + + PaymentConfirmResult result = adapter.confirmPayment("key-1", "order-1", 10000); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.totalAmount()).isEqualTo(10000); + assertThat(result.errorCode()).isNull(); + assertThat(result.errorMessage()).isNull(); + } + + @Test + @DisplayName("실패 응답을 정상적으로 파싱한다") + void failureResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody(""" + {"isSuccess": false, "totalAmount": 0, "errorCode": "INVALID_AMOUNT", "errorMessage": "금액 불일치"} + """) + .setHeader("Content-Type", "application/json")); + + PaymentConfirmResult result = adapter.confirmPayment("key-1", "order-1", 10000); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.errorCode()).isEqualTo("INVALID_AMOUNT"); + assertThat(result.errorMessage()).isEqualTo("금액 불일치"); + } + + @Test + @DisplayName("잘못된 JSON 응답이면 PARSE_ERROR를 반환한다") + void invalidJsonResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody("not a json") + .setHeader("Content-Type", "application/json")); + + PaymentConfirmResult result = adapter.confirmPayment("key-1", "order-1", 10000); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.errorCode()).isEqualTo("PARSE_ERROR"); + } + + @Test + @DisplayName("읽기 타임아웃이 발생하면 예외가 발생한다") + void readTimeout() { + mockWebServer.enqueue(new MockResponse() + .setBody("{}") + .setHeadersDelay(6, TimeUnit.SECONDS)); + + assertThatThrownBy(() -> adapter.confirmPayment("key-1", "order-1", 10000)) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("결제 취소 (cancelPayment)") + class CancelPayment { + + @Test + @DisplayName("성공 응답을 정상적으로 파싱한다") + void successResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody(""" + {"isSuccess": true, "cancelAmount": 5000, "transactionKey": "txn-1", "errorCode": null, "errorMessage": null} + """) + .setHeader("Content-Type", "application/json")); + + PaymentCancelResult result = adapter.cancelPayment("order-1", 5000, "고객 요청"); + + assertThat(result.isSuccess()).isTrue(); + assertThat(result.cancelAmount()).isEqualTo(5000); + assertThat(result.transactionKey()).isEqualTo("txn-1"); + assertThat(result.errorCode()).isNull(); + } + + @Test + @DisplayName("실패 응답을 정상적으로 파싱한다") + void failureResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody(""" + {"isSuccess": false, "cancelAmount": 0, "transactionKey": null, "errorCode": "ALREADY_CANCELED", "errorMessage": "이미 취소됨"} + """) + .setHeader("Content-Type", "application/json")); + + PaymentCancelResult result = adapter.cancelPayment("order-1", 5000, "고객 요청"); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.errorCode()).isEqualTo("ALREADY_CANCELED"); + } + + @Test + @DisplayName("잘못된 JSON 응답이면 PARSE_ERROR를 반환한다") + void invalidJsonResponse() { + mockWebServer.enqueue(new MockResponse() + .setBody("broken") + .setHeader("Content-Type", "application/json")); + + PaymentCancelResult result = adapter.cancelPayment("order-1", 5000, "고객 요청"); + + assertThat(result.isSuccess()).isFalse(); + assertThat(result.errorCode()).isEqualTo("PARSE_ERROR"); + } + + @Test + @DisplayName("읽기 타임아웃이 발생하면 예외가 발생한다") + void readTimeout() { + mockWebServer.enqueue(new MockResponse() + .setBody("{}") + .setHeadersDelay(6, TimeUnit.SECONDS)); + + assertThatThrownBy(() -> adapter.cancelPayment("order-1", 5000, "고객 요청")) + .isInstanceOf(Exception.class); + } + } + + @Nested + @DisplayName("서버 오류 응답") + class ServerError { + + @Test + @DisplayName("500 응답이면 예외가 발생한다") + void confirm_serverError() { + mockWebServer.enqueue(new MockResponse().setResponseCode(500)); + + assertThatThrownBy(() -> adapter.confirmPayment("key-1", "order-1", 10000)) + .isInstanceOf(Exception.class); + } + + @Test + @DisplayName("500 응답이면 취소도 예외가 발생한다") + void cancel_serverError() { + mockWebServer.enqueue(new MockResponse().setResponseCode(500)); + + assertThatThrownBy(() -> adapter.cancelPayment("order-1", 5000, "고객 요청")) + .isInstanceOf(Exception.class); + } + } +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptorTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapterTest.java similarity index 78% rename from payment/src/test/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptorTest.java rename to payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapterTest.java index d2d83b23..9ff79fad 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adaptor/StubPaymentGatewayAdaptorTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/adapter/StubPaymentGatewayAdapterTest.java @@ -1,4 +1,4 @@ -package wisoft.nextframe.payment.infra.payment.adaptor; +package wisoft.nextframe.payment.infra.payment.adapter; import static org.assertj.core.api.Assertions.*; @@ -9,9 +9,9 @@ import wisoft.nextframe.payment.application.payment.port.output.PaymentGateway; -@SpringBootTest(classes = StubPaymentGatewayAdaptor.class) -@ActiveProfiles("loadtest") // StubPaymentGatewayAdaptor만 활성화 -class StubPaymentGatewayAdaptorTest { +@SpringBootTest(classes = StubPaymentGatewayAdapter.class) +@ActiveProfiles("loadtest") // StubPaymentGatewayAdapter만 활성화 +class StubPaymentGatewayAdapterTest { @Autowired private PaymentGateway paymentGateway; // Stub이 주입됨 diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImplTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImplTest.java deleted file mode 100644 index f22e2c83..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/outbox/ticketissue/TicketIssueOutboxRepositoryImplTest.java +++ /dev/null @@ -1,82 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.outbox.ticketissue; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.time.LocalDateTime; -import java.util.Optional; -import java.util.UUID; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.core.env.Environment; - -@ExtendWith(MockitoExtension.class) -class TicketIssueOutboxRepositoryImplTest { - - @Mock - private JpaTicketIssueOutboxRepository jpa; - - @Mock - private Environment env; - - @InjectMocks - private TicketIssueOutboxRepositoryImpl repository; - - private final UUID reservationId = UUID.randomUUID(); - private final UUID paymentId = UUID.randomUUID(); - private final LocalDateTime now = LocalDateTime.now(); - - @Test - @DisplayName("기존 기록이 없으면 새로운 PENDING 레코드를 생성한다") - void upsertPending_createNew() { - // given: DB에 데이터가 없음 - given(jpa.findByReservationId(reservationId)).willReturn(Optional.empty()); - - // when - repository.upsertPending(paymentId, reservationId, "error", now); - - // then: 새로운 엔티티가 PENDING 상태로 저장되는지 검증 - ArgumentCaptor captor = ArgumentCaptor.forClass(TicketIssueOutboxEntity.class); - verify(jpa).save(captor.capture()); - - assertThat(captor.getValue().getStatus()).isEqualTo("PENDING"); - assertThat(captor.getValue().getReservationId()).isEqualTo(reservationId); - } - - @Test - @DisplayName("이미 PENDING 상태라면 에러 메시지와 시간을 업데이트한다") - void upsertPending_updateExisting() { - // given: 기존 PENDING 데이터 존재 - TicketIssueOutboxEntity existing = new TicketIssueOutboxEntity(); - existing.setStatus("PENDING"); - given(jpa.findByReservationId(reservationId)).willReturn(Optional.of(existing)); - - // when - repository.upsertPending(paymentId, reservationId, "new error", now); - - // then: lastError가 업데이트되었는지 확인 - assertThat(existing.getLastError()).isEqualTo("new error"); - verify(jpa).save(existing); - } - - @Test - @DisplayName("이미 SUCCESS 상태인 레코드는 수정하지 않고 리턴한다") - void upsertPending_ignoreIfSuccess() { - // given: 이미 SUCCESS인 데이터 존재 - TicketIssueOutboxEntity existing = new TicketIssueOutboxEntity(); - existing.setStatus("SUCCESS"); - given(jpa.findByReservationId(reservationId)).willReturn(Optional.of(existing)); - - // when - repository.upsertPending(paymentId, reservationId, "any error", now); - - // then: save()가 호출되지 않아야 함 (데이터 보호) - verify(jpa, never()).save(any()); - } -} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/PaymentMapperTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/PaymentMapperTest.java index 60997ed6..55c42998 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/PaymentMapperTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/PaymentMapperTest.java @@ -1,6 +1,10 @@ package wisoft.nextframe.payment.infra.payment.persistence; import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -9,11 +13,14 @@ import wisoft.nextframe.payment.domain.payment.Payment; import wisoft.nextframe.payment.infra.payment.PaymentEntity; import wisoft.nextframe.payment.infra.payment.PaymentMapper; +import wisoft.nextframe.payment.infra.refund.JpaRefundRepository; +import wisoft.nextframe.payment.infra.refund.RefundMapper; public class PaymentMapperTest { - private final PaymentMapper mapper = new PaymentMapper(); - + private final JpaRefundRepository jpaRefundRepository = mock(JpaRefundRepository.class); + private final RefundMapper refundMapper = new RefundMapper(); + private final PaymentMapper mapper = new PaymentMapper(jpaRefundRepository, refundMapper); @Test @DisplayName("도메인 Payment를 PaymentEntity로 변환한다") @@ -36,6 +43,7 @@ public void toEntity_shouldMapAllFields() { public void toDomain_shouldMapAllFields() { // given PaymentEntity entity = PaymentEntityFixture.sampleEntity(); + when(jpaRefundRepository.findByPaymentId(any())).thenReturn(Optional.empty()); // when Payment payment = mapper.toDomain(entity); diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/RefundMapperTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/RefundMapperTest.java deleted file mode 100644 index a68464cd..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/persistence/RefundMapperTest.java +++ /dev/null @@ -1,55 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.persistence; - -import static org.assertj.core.api.Assertions.*; - -import java.util.UUID; - -import org.junit.jupiter.api.Test; - -import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundEntity; -import wisoft.nextframe.payment.domain.refund.RefundMapper; -import wisoft.nextframe.payment.infra.refund.RefundEntityFixture; - -public class RefundMapperTest { - - private final RefundMapper mapper = new RefundMapper(); - - @Test - void toDomain_정상_매핑() { - // given - RefundEntity entity = RefundEntityFixture.sampleEntity(); - - // when - Refund domain = mapper.toDomain(entity); - - // then - assertThat(domain.getRefundId().getValue()).isEqualTo(entity.getId()); - assertThat(domain.getRefundedAmount().getValue().intValue()).isEqualTo(entity.getRefundAmount()); - assertThat(domain.getStatus().name()).isEqualTo(entity.getStatus()); - assertThat(domain.getPolicyStatus().name()).isEqualTo(entity.getRefundPolicy()); - assertThat(domain.getRequestedAt()).isEqualTo(entity.getRequestedAt()); - assertThat(domain.getCompletedAt()).isEqualTo(entity.getCompletedAt()); - } - - @Test - void toEntity_정상_매핑() { - // given - Refund domain = RefundEntityFixture.sampleDomain(); - UUID paymentId = RefundEntityFixture.DEFAULT_PAYMENT_ID; - String reason = RefundEntityFixture.DEFAULT_REASON; - - // when - RefundEntity entity = mapper.toEntity(domain, paymentId, reason); - - // then - assertThat(entity.getId()).isEqualTo(domain.getRefundId().getValue()); - assertThat(entity.getRefundAmount()).isEqualTo(domain.getRefundedAmount().getValue().intValue()); - assertThat(entity.getStatus()).isEqualTo(domain.getStatus().name()); - assertThat(entity.getRefundPolicy()).isEqualTo(domain.getPolicyStatus().name()); - assertThat(entity.getRequestedAt()).isEqualTo(domain.getRequestedAt()); - assertThat(entity.getCompletedAt()).isEqualTo(domain.getCompletedAt()); - assertThat(entity.getPaymentId()).isEqualTo(paymentId); - assertThat(entity.getReason()).isEqualTo(reason); - } -} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueSchedulerTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueSchedulerTest.java deleted file mode 100644 index 085d3444..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/payment/schedule/TicketIssueSchedulerTest.java +++ /dev/null @@ -1,48 +0,0 @@ -package wisoft.nextframe.payment.infra.payment.schedule; - -import static org.mockito.BDDMockito.*; - -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 wisoft.nextframe.payment.application.payment.outbox.ticketissue.TicketIssueRetryUseCase; - -@ExtendWith(MockitoExtension.class) -class TicketIssueSchedulerTest { - - @Mock - TicketIssueRetryUseCase retryUseCase; - - @InjectMocks - TicketIssueScheduler scheduler; - - @Test - @DisplayName("PENDING이 0이면 runOnce()를 호출하지 않는다.") - void whenNoPending_doesNotRunOnce() { - // given - given(retryUseCase.hasPending()).willReturn(false); - - // when - scheduler.retryTicketIssue(); - - // then - then(retryUseCase).should(never()).runOnce(); - } - - @Test - @DisplayName("PENDING 있으면 runOnce() 호출된다") - void whenPendingExists_runsOnce() { - // given - given(retryUseCase.hasPending()).willReturn(true); - - // when - scheduler.retryTicketIssue(); - - // then - then(retryUseCase).should(times(1)).runOnce(); - } -} diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundEntityFixture.java b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundEntityFixture.java index 81071e7e..2b536e6b 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundEntityFixture.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundEntityFixture.java @@ -5,7 +5,7 @@ import wisoft.nextframe.payment.common.Money; import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundEntity; +import wisoft.nextframe.payment.infra.refund.RefundEntity; import wisoft.nextframe.payment.domain.refund.RefundId; import wisoft.nextframe.payment.domain.refund.RefundPolicyStatus; import wisoft.nextframe.payment.domain.refund.RefundStatus; diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundMapperTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundMapperTest.java index 4a9a6fab..a9db451d 100644 --- a/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundMapperTest.java +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundMapperTest.java @@ -7,8 +7,8 @@ import org.junit.jupiter.api.Test; import wisoft.nextframe.payment.domain.refund.Refund; -import wisoft.nextframe.payment.domain.refund.RefundEntity; -import wisoft.nextframe.payment.domain.refund.RefundMapper; +import wisoft.nextframe.payment.infra.refund.RefundEntity; +import wisoft.nextframe.payment.infra.refund.RefundMapper; public class RefundMapperTest { diff --git a/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImplTest.java b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImplTest.java new file mode 100644 index 00000000..7bf6916d --- /dev/null +++ b/payment/src/test/java/wisoft/nextframe/payment/infra/refund/RefundRepositoryImplTest.java @@ -0,0 +1,84 @@ +package wisoft.nextframe.payment.infra.refund; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import wisoft.nextframe.payment.domain.refund.Refund; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RefundRepositoryImpl 단위 테스트") +class RefundRepositoryImplTest { + + @Mock + JpaRefundRepository jpaRefundRepository; + + @Mock + RefundMapper refundMapper; + + @InjectMocks + RefundRepositoryImpl refundRepository; + + @Test + @DisplayName("환불 저장 시 엔티티로 변환하여 저장하고 도메인으로 반환한다") + void save_shouldPersistAndReturnMappedRefund() { + // given + Refund domain = RefundEntityFixture.sampleDomain(); + UUID paymentId = RefundEntityFixture.DEFAULT_PAYMENT_ID; + String reason = RefundEntityFixture.DEFAULT_REASON; + RefundEntity entity = RefundEntityFixture.sampleEntity(); + + given(refundMapper.toEntity(domain, paymentId, reason)).willReturn(entity); + given(jpaRefundRepository.save(entity)).willReturn(entity); + given(refundMapper.toDomain(entity)).willReturn(domain); + + // when + Refund result = refundRepository.save(domain, paymentId, reason); + + // then + assertThat(result).isEqualTo(domain); + then(jpaRefundRepository).should().save(entity); + } + + @Test + @DisplayName("paymentId로 환불 조회 시 존재하면 도메인 객체를 반환한다") + void findByPaymentId_shouldReturnRefund_whenFound() { + // given + UUID paymentId = RefundEntityFixture.DEFAULT_PAYMENT_ID; + RefundEntity entity = RefundEntityFixture.sampleEntity(); + Refund domain = RefundEntityFixture.sampleDomain(); + + given(jpaRefundRepository.findByPaymentId(paymentId)).willReturn(Optional.of(entity)); + given(refundMapper.toDomain(entity)).willReturn(domain); + + // when + Optional result = refundRepository.findByPaymentId(paymentId); + + // then + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(domain); + } + + @Test + @DisplayName("paymentId로 환불 조회 시 존재하지 않으면 빈 Optional을 반환한다") + void findByPaymentId_shouldReturnEmpty_whenNotFound() { + // given + UUID paymentId = UUID.randomUUID(); + given(jpaRefundRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); + + // when + Optional result = refundRepository.findByPaymentId(paymentId); + + // then + assertThat(result).isEmpty(); + } +} diff --git a/payment/src/test/java/wisoft/nextframe/payment/repository/PaymentRepositoryImplTest.java b/payment/src/test/java/wisoft/nextframe/payment/repository/PaymentRepositoryImplTest.java deleted file mode 100644 index 2208835e..00000000 --- a/payment/src/test/java/wisoft/nextframe/payment/repository/PaymentRepositoryImplTest.java +++ /dev/null @@ -1,86 +0,0 @@ -package wisoft.nextframe.payment.repository; - -import static org.assertj.core.api.Assertions.*; -import static org.mockito.BDDMockito.*; - -import java.util.Optional; -import java.util.UUID; - -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 wisoft.nextframe.payment.domain.payment.Payment; -import wisoft.nextframe.payment.domain.payment.PaymentId; -import wisoft.nextframe.payment.domain.fixture.PaymentEntityFixture; -import wisoft.nextframe.payment.infra.payment.JpaPaymentRepository; -import wisoft.nextframe.payment.infra.payment.PaymentEntity; -import wisoft.nextframe.payment.infra.payment.PaymentMapper; -import wisoft.nextframe.payment.infra.payment.PaymentRepositoryImpl; - -@ExtendWith(MockitoExtension.class) -class PaymentRepositoryImplTest { - - @Mock - JpaPaymentRepository jpaPaymentRepository; - - @Mock - PaymentMapper paymentMapper; - - @InjectMocks - PaymentRepositoryImpl paymentRepository; - - @Test - void findById_should_return_payment_when_found() { - // given - UUID uuid = PaymentEntityFixture.DEFAULT_PAYMENT_ID; - PaymentId id = PaymentId.of(uuid); - PaymentEntity entity = PaymentEntityFixture.sampleEntity(); - Payment domain = PaymentEntityFixture.sampleDomain(); - - given(jpaPaymentRepository.findById(uuid)).willReturn(Optional.of(entity)); - given(paymentMapper.toDomain(entity)).willReturn(domain); - - // when - Optional result = paymentRepository.findById(id); - - // then - assertThat(result).isPresent(); - assertThat(result.get()).isEqualTo(domain); - } - - @Test - void findById_should_return_empty_when_not_found() { - // given - UUID uuid = UUID.randomUUID(); - PaymentId id = PaymentId.of(uuid); - - given(jpaPaymentRepository.findById(uuid)).willReturn(Optional.empty()); - - // when - Optional result = paymentRepository.findById(id); - - // then - assertThat(result).isEmpty(); - } - - @Test - void save_should_persist_and_return_mapped_payment() { - // given - Payment domain = PaymentEntityFixture.sampleDomain(); - PaymentEntity entity = PaymentEntityFixture.sampleEntity(); - - given(paymentMapper.toEntity(domain)).willReturn(entity); - given(jpaPaymentRepository.save(entity)).willReturn(entity); - given(paymentMapper.toDomain(entity)).willReturn(domain); - - // when - Payment result = paymentRepository.save(domain); - - // then - assertThat(result).isEqualTo(domain); - } - -} diff --git a/payment/src/test/resources/schema-test.sql b/payment/src/test/resources/schema-test.sql index 43bada04..467461fe 100644 --- a/payment/src/test/resources/schema-test.sql +++ b/payment/src/test/resources/schema-test.sql @@ -16,34 +16,28 @@ CREATE TABLE IF NOT EXISTS payments status varchar DEFAULT 'REQUESTED' ); -CREATE TABLE IF NOT EXISTS ticket_issue_outbox +CREATE TABLE IF NOT EXISTS refunds ( - id - uuid - DEFAULT - gen_random_uuid -( -) NOT NULL PRIMARY KEY, + id uuid PRIMARY KEY, payment_id uuid NOT NULL, - reservation_id uuid NOT NULL UNIQUE, - ticket_id uuid, - status varchar NOT NULL DEFAULT 'PENDING', - retry_count int NOT NULL DEFAULT 0, - next_retry_at timestamp NOT NULL DEFAULT now -( -), - last_error varchar, - created_at timestamp NOT NULL DEFAULT now -( -), - updated_at timestamp NOT NULL DEFAULT now + refund_amount integer NOT NULL, + status varchar NOT NULL, + reason varchar, + refund_policy varchar, + requested_at timestamp, + completed_at timestamp +); + +CREATE TABLE IF NOT EXISTS schedules ( -) - ); + id uuid PRIMARY KEY, + performance_datetime timestamp NOT NULL +); create table reservations ( - id uuid primary key + id uuid primary key, + schedule_id uuid ); CREATE TABLE reservation_cancel_outbox ( diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceController.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceController.java index e506e674..076c0a34 100644 --- a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceController.java +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceController.java @@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import lombok.RequiredArgsConstructor; @@ -19,6 +20,8 @@ import wisoft.nextframe.schedulereservationticketing.dto.performance.performancedetail.response.PerformanceDetailResponse; import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceListResponse; import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.Top10PerformanceListResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.response.PerformanceSearchResponse; import wisoft.nextframe.schedulereservationticketing.service.performance.PerformanceService; @Slf4j @@ -48,6 +51,17 @@ public ResponseEntity> getPerformanceList(@PageableDefault(size = return new ResponseEntity<>(response, HttpStatus.OK); } + @GetMapping("/search") + public ResponseEntity> searchPerformances( + @RequestParam(required = false) String keyword, + @RequestParam(required = false) PerformanceSearchSort sort, + @PageableDefault(size = 32) Pageable pageable + ) { + final PerformanceSearchResponse data = performanceService.searchPerformances(keyword, sort, pageable); + final ApiResponse response = ApiResponse.success(data); + return ResponseEntity.ok(response); + } + @GetMapping("/top10") public ResponseEntity> getTop10Performances() { final Top10PerformanceListResponse data = performanceService.getTop10Performances(); diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchCondition.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchCondition.java new file mode 100644 index 00000000..372a4e04 --- /dev/null +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchCondition.java @@ -0,0 +1,22 @@ +package wisoft.nextframe.schedulereservationticketing.dto.performance.search.request; + +import org.springframework.data.domain.Pageable; + +public record PerformanceSearchCondition( + String keyword, + PerformanceSearchSort sort, + Pageable pageable +) { + + public static PerformanceSearchCondition of( + final String keyword, + final PerformanceSearchSort sort, + final Pageable pageable + ) { + final String normalizedKeyword = (keyword != null) ? keyword.strip() : null; + final String effectiveKeyword = (normalizedKeyword != null && normalizedKeyword.isEmpty()) ? null : normalizedKeyword; + final PerformanceSearchSort effectiveSort = (sort != null) ? sort : PerformanceSearchSort.LATEST; + + return new PerformanceSearchCondition(effectiveKeyword, effectiveSort, pageable); + } +} diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchSort.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchSort.java new file mode 100644 index 00000000..410b0c2f --- /dev/null +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchSort.java @@ -0,0 +1,9 @@ +package wisoft.nextframe.schedulereservationticketing.dto.performance.search.request; + +public enum PerformanceSearchSort { + LATEST, + HIT_DESC, + STAR_DESC, + DATE_ASC, + DATE_DESC +} diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/response/PerformanceSearchResponse.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/response/PerformanceSearchResponse.java new file mode 100644 index 00000000..e1fa4cb7 --- /dev/null +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/response/PerformanceSearchResponse.java @@ -0,0 +1,28 @@ +package wisoft.nextframe.schedulereservationticketing.dto.performance.search.response; + +import java.util.List; + +import org.springframework.data.domain.Page; + +import lombok.Builder; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PaginationResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; + +@Builder +public record PerformanceSearchResponse( + String keyword, + List performances, + PaginationResponse pagination +) { + + public static PerformanceSearchResponse from( + final String keyword, + final Page performancePage + ) { + return PerformanceSearchResponse.builder() + .keyword(keyword) + .performances(performancePage.getContent()) + .pagination(PaginationResponse.from(performancePage)) + .build(); + } +} diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/PerformanceSearchPort.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/PerformanceSearchPort.java new file mode 100644 index 00000000..410cf3ff --- /dev/null +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/PerformanceSearchPort.java @@ -0,0 +1,11 @@ +package wisoft.nextframe.schedulereservationticketing.repository.performance; + +import org.springframework.data.domain.Page; + +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchCondition; + +public interface PerformanceSearchPort { + + Page search(PerformanceSearchCondition condition); +} diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapter.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapter.java new file mode 100644 index 00000000..b285ea92 --- /dev/null +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapter.java @@ -0,0 +1,160 @@ +package wisoft.nextframe.schedulereservationticketing.repository.performance; + +import static wisoft.nextframe.schedulereservationticketing.entity.performance.QPerformance.*; +import static wisoft.nextframe.schedulereservationticketing.entity.performance.QPerformanceStatistic.*; +import static wisoft.nextframe.schedulereservationticketing.entity.schedule.QSchedule.*; +import static wisoft.nextframe.schedulereservationticketing.entity.stadium.QStadium.*; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.support.PageableExecutionUtils; +import org.springframework.stereotype.Component; + +import com.querydsl.core.types.ConstructorExpression; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateTemplate; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.JPAExpressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import lombok.RequiredArgsConstructor; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchCondition; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.entity.schedule.QSchedule; + +@Component +@RequiredArgsConstructor +public class QueryDslPerformanceSearchAdapter implements PerformanceSearchPort { + + private final JPAQueryFactory queryFactory; + + @Override + public Page search(final PerformanceSearchCondition condition) { + final QSchedule subSchedule = new QSchedule("subSchedule"); + final LocalDateTime now = LocalDateTime.now(); + final PerformanceSearchSort sort = condition.sort(); + final boolean needStatisticJoin = needsStatisticJoin(sort); + + // 컨텐츠 쿼리 + JPAQuery contentQuery = queryFactory + .select(performanceSummaryProjection()) + .from(schedule) + .join(schedule.performance, performance) + .join(schedule.stadium, stadium); + + if (needStatisticJoin) { + contentQuery = contentQuery + .join(performanceStatistic).on(performanceStatistic.performance.eq(performance)); + } + + contentQuery = contentQuery + .where( + ticketOnSale(subSchedule, now), + nameContains(condition.keyword()) + ) + .groupBy( + performance.id, + performance.name, + performance.imageUrl, + performance.type, + performance.genre, + stadium.name, + performance.adultOnly + ); + + if (sort == PerformanceSearchSort.HIT_DESC) { + contentQuery = contentQuery.groupBy(performanceStatistic.hit); + } else if (sort == PerformanceSearchSort.STAR_DESC) { + contentQuery = contentQuery.groupBy(performanceStatistic.averageStar); + } + + final List content = contentQuery + .orderBy(resolveSort(sort)) + .offset(condition.pageable().getOffset()) + .limit(condition.pageable().getPageSize()) + .fetch(); + + // count 쿼리 + return PageableExecutionUtils.getPage(content, condition.pageable(), + () -> queryFactory + .select(performance.id) + .from(schedule) + .join(schedule.performance, performance) + .join(schedule.stadium, stadium) + .where( + ticketOnSale(subSchedule, now), + nameContains(condition.keyword()) + ) + .groupBy(performance.id, stadium.name) + .fetch() + .size() + ); + } + + private BooleanExpression nameContains(final String keyword) { + if (keyword == null) { + return null; + } + return performance.name.containsIgnoreCase(keyword); + } + + private boolean needsStatisticJoin(final PerformanceSearchSort sort) { + return sort == PerformanceSearchSort.HIT_DESC || sort == PerformanceSearchSort.STAR_DESC; + } + + private OrderSpecifier[] resolveSort(final PerformanceSearchSort sort) { + return switch (sort) { + case HIT_DESC -> new OrderSpecifier[] { + performanceStatistic.hit.desc(), + schedule.performanceDatetime.min().asc() + }; + case STAR_DESC -> new OrderSpecifier[] { + performanceStatistic.averageStar.desc(), + schedule.performanceDatetime.min().asc() + }; + case DATE_ASC -> new OrderSpecifier[] { + schedule.performanceDatetime.min().asc() + }; + case DATE_DESC -> new OrderSpecifier[] { + schedule.performanceDatetime.min().desc() + }; + case LATEST -> new OrderSpecifier[] { + schedule.performanceDatetime.min().desc() + }; + }; + } + + private BooleanExpression ticketOnSale(final QSchedule subSchedule, final LocalDateTime now) { + return JPAExpressions.selectOne() + .from(subSchedule) + .where( + subSchedule.performance.id.eq(performance.id), + subSchedule.ticketOpenTime.loe(now), + subSchedule.ticketCloseTime.goe(now) + ) + .exists(); + } + + private ConstructorExpression performanceSummaryProjection() { + final DateTemplate performanceDate = + Expressions.dateTemplate(java.sql.Date.class, "CAST({0} AS date)", schedule.performanceDatetime); + + return Projections.constructor(PerformanceSummaryResponse.class, + performance.id, + performance.name, + performance.imageUrl, + performance.type, + performance.genre, + stadium.name, + performanceDate.min(), + performanceDate.max(), + performance.adultOnly + ); + } +} diff --git a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceService.java b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceService.java index afbff1ac..38cbe5af 100644 --- a/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceService.java +++ b/schedule-reservation-ticketing/src/main/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceService.java @@ -19,12 +19,16 @@ import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceListResponse; import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.Top10PerformanceListResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchCondition; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.response.PerformanceSearchResponse; import wisoft.nextframe.schedulereservationticketing.entity.performance.Performance; import wisoft.nextframe.schedulereservationticketing.entity.performance.PerformanceStatistic; import wisoft.nextframe.schedulereservationticketing.entity.schedule.Schedule; import wisoft.nextframe.schedulereservationticketing.common.exception.DomainException; import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformancePricingRepository; import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceRepository; +import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceSearchPort; import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceStatisticRepository; import wisoft.nextframe.schedulereservationticketing.repository.schedule.ScheduleRepository; @@ -35,6 +39,7 @@ public class PerformanceService { private final PerformanceRepository performanceRepository; + private final PerformanceSearchPort performanceSearchPort; private final ScheduleRepository scheduleRepository; private final PerformancePricingRepository performancePricingRepository; private final PerformanceStatisticRepository performanceStatisticRepository; @@ -70,6 +75,18 @@ public PerformanceDetailResponse getPerformanceDetail(UUID performanceId) { return PerformanceDetailResponse.from(performance, schedules, seatSectionPrices, performanceStatistic); } + public PerformanceSearchResponse searchPerformances( + final String keyword, + final PerformanceSearchSort sort, + final Pageable pageable + ) { + final PerformanceSearchCondition condition = PerformanceSearchCondition.of(keyword, sort, pageable); + final Page performancePage = performanceSearchPort.search(condition); + log.debug("공연 검색 완료. keyword: {}, sort: {}, 결과 수: {}", condition.keyword(), condition.sort(), performancePage.getTotalElements()); + + return PerformanceSearchResponse.from(condition.keyword(), performancePage); + } + @Cacheable(value = "performanceList", key = "#pageable.pageNumber + '-' + #pageable.pageSize + '-' + #pageable.sort") public PerformanceListResponse getPerformanceList(Pageable pageable) { // 1. PerformanceSummaryResponse로 구성된 공연 목록 Page 객체 조회 diff --git a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/builder/PerformanceStatisticBuilder.java b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/builder/PerformanceStatisticBuilder.java index 5d8d1c32..c6fc102c 100644 --- a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/builder/PerformanceStatisticBuilder.java +++ b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/builder/PerformanceStatisticBuilder.java @@ -32,6 +32,11 @@ public PerformanceStatisticBuilder withHit(int hit) { return this; } + public PerformanceStatisticBuilder withAverageStar(BigDecimal averageStar) { + this.averageStar = averageStar; + return this; + } + public PerformanceStatistic build() { return new PerformanceStatistic(performanceId, hit, averageStar, updatedAt, performance); } diff --git a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceSearchControllerTest.java b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceSearchControllerTest.java new file mode 100644 index 00000000..2e3208c7 --- /dev/null +++ b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/controller/performance/PerformanceSearchControllerTest.java @@ -0,0 +1,201 @@ +package wisoft.nextframe.schedulereservationticketing.controller.performance; + +import static org.hamcrest.Matchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.context.annotation.FilterType; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import wisoft.nextframe.schedulereservationticketing.config.jwt.JwtAuthenticationFilter; +import wisoft.nextframe.schedulereservationticketing.config.security.SecurityConfig; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PaginationResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.response.PerformanceSearchResponse; +import wisoft.nextframe.schedulereservationticketing.entity.performance.PerformanceGenre; +import wisoft.nextframe.schedulereservationticketing.entity.performance.PerformanceType; +import wisoft.nextframe.schedulereservationticketing.service.auth.DynamicAuthService; +import wisoft.nextframe.schedulereservationticketing.service.performance.PerformanceService; + +@WebMvcTest(value = PerformanceController.class, + excludeFilters = @ComponentScan.Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = {SecurityConfig.class, JwtAuthenticationFilter.class} + ) +) +class PerformanceSearchControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private PerformanceService performanceService; + + @MockitoBean + private DynamicAuthService dynamicAuthService; + + @Nested + @DisplayName("공연 검색 API 테스트") + class SearchPerformancesTest { + + @Test + @DisplayName("키워드로 공연을 검색한다 - 성공 (200 OK)") + @WithMockUser + void searchPerformances_withKeyword_success() throws Exception { + // given + PerformanceSummaryResponse summary = new PerformanceSummaryResponse( + UUID.randomUUID(), + "햄릿", + "https://example.com/hamlet.jpg", + PerformanceType.CLASSIC, + PerformanceGenre.PLAY, + "대전예술의전당", + LocalDate.of(2025, 9, 1), + LocalDate.of(2025, 9, 30), + false + ); + + PaginationResponse pagination = PaginationResponse.builder() + .page(0) + .size(32) + .totalItems(1) + .totalPages(1) + .hasNext(false) + .hasPrevious(false) + .build(); + + PerformanceSearchResponse searchResponse = PerformanceSearchResponse.builder() + .keyword("햄릿") + .performances(List.of(summary)) + .pagination(pagination) + .build(); + + when(performanceService.searchPerformances(eq("햄릿"), eq(PerformanceSearchSort.HIT_DESC), any())) + .thenReturn(searchResponse); + + // when & then + mockMvc.perform(get("/api/v1/performances/search") + .param("keyword", "햄릿") + .param("sort", "HIT_DESC") + .param("page", "0") + .param("size", "32") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.data.keyword").value("햄릿")) + .andExpect(jsonPath("$.data.performances", hasSize(1))) + .andExpect(jsonPath("$.data.performances[0].name").value("햄릿")) + .andExpect(jsonPath("$.data.performances[0].stadiumName").value("대전예술의전당")) + .andExpect(jsonPath("$.data.performances[0].startDate").value("2025-09-01")) + .andExpect(jsonPath("$.data.performances[0].endDate").value("2025-09-30")) + .andExpect(jsonPath("$.data.pagination.totalItems").value(1)) + .andExpect(jsonPath("$.data.pagination.page").value(0)) + .andExpect(jsonPath("$.data.pagination.size").value(32)); + } + + @Test + @DisplayName("키워드 없이 전체 검색한다 - 성공 (200 OK)") + @WithMockUser + void searchPerformances_withoutKeyword_success() throws Exception { + // given + PerformanceSearchResponse searchResponse = PerformanceSearchResponse.builder() + .keyword(null) + .performances(List.of()) + .pagination(PaginationResponse.builder() + .page(0).size(32).totalItems(0).totalPages(0) + .hasNext(false).hasPrevious(false).build()) + .build(); + + when(performanceService.searchPerformances(isNull(), isNull(), any())) + .thenReturn(searchResponse); + + // when & then + mockMvc.perform(get("/api/v1/performances/search") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")) + .andExpect(jsonPath("$.data.keyword").doesNotExist()) + .andExpect(jsonPath("$.data.performances", hasSize(0))) + .andExpect(jsonPath("$.data.pagination.totalItems").value(0)); + } + + @Test + @DisplayName("정렬 기준을 지정하여 검색한다 - 성공 (200 OK)") + @WithMockUser + void searchPerformances_withSort_success() throws Exception { + // given + PerformanceSearchResponse searchResponse = PerformanceSearchResponse.builder() + .keyword(null) + .performances(List.of()) + .pagination(PaginationResponse.builder() + .page(0).size(32).totalItems(0).totalPages(0) + .hasNext(false).hasPrevious(false).build()) + .build(); + + when(performanceService.searchPerformances(isNull(), eq(PerformanceSearchSort.STAR_DESC), any())) + .thenReturn(searchResponse); + + // when & then + mockMvc.perform(get("/api/v1/performances/search") + .param("sort", "STAR_DESC") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("SUCCESS")); + + verify(performanceService).searchPerformances(isNull(), eq(PerformanceSearchSort.STAR_DESC), any()); + } + + @Test + @DisplayName("페이지네이션 파라미터를 지정하여 검색한다 - 성공 (200 OK)") + @WithMockUser + void searchPerformances_withPagination_success() throws Exception { + // given + PaginationResponse pagination = PaginationResponse.builder() + .page(2) + .size(16) + .totalItems(50) + .totalPages(4) + .hasNext(true) + .hasPrevious(true) + .build(); + + PerformanceSearchResponse searchResponse = PerformanceSearchResponse.builder() + .keyword("뮤지컬") + .performances(List.of()) + .pagination(pagination) + .build(); + + when(performanceService.searchPerformances(eq("뮤지컬"), isNull(), any())) + .thenReturn(searchResponse); + + // when & then + mockMvc.perform(get("/api/v1/performances/search") + .param("keyword", "뮤지컬") + .param("page", "2") + .param("size", "16") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.pagination.page").value(2)) + .andExpect(jsonPath("$.data.pagination.size").value(16)) + .andExpect(jsonPath("$.data.pagination.totalItems").value(50)) + .andExpect(jsonPath("$.data.pagination.totalPages").value(4)) + .andExpect(jsonPath("$.data.pagination.hasNext").value(true)) + .andExpect(jsonPath("$.data.pagination.hasPrevious").value(true)); + } + } +} diff --git a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchConditionTest.java b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchConditionTest.java new file mode 100644 index 00000000..0cebcb8e --- /dev/null +++ b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/dto/performance/search/request/PerformanceSearchConditionTest.java @@ -0,0 +1,108 @@ +package wisoft.nextframe.schedulereservationticketing.dto.performance.search.request; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; + +class PerformanceSearchConditionTest { + + private final Pageable pageable = PageRequest.of(0, 32); + + @Nested + @DisplayName("keyword 정규화 테스트") + class KeywordNormalizationTest { + + @Test + @DisplayName("null 키워드는 그대로 null을 유지한다") + void nullKeyword_remainsNull() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, null, pageable); + + // then + assertThat(condition.keyword()).isNull(); + } + + @Test + @DisplayName("빈 문자열 키워드는 null로 변환된다") + void emptyKeyword_becomesNull() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of("", null, pageable); + + // then + assertThat(condition.keyword()).isNull(); + } + + @Test + @DisplayName("공백만 있는 키워드는 null로 변환된다") + void blankKeyword_becomesNull() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of(" ", null, pageable); + + // then + assertThat(condition.keyword()).isNull(); + } + + @Test + @DisplayName("키워드 앞뒤 공백이 제거된다") + void keyword_isStripped() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of(" 햄릿 ", null, pageable); + + // then + assertThat(condition.keyword()).isEqualTo("햄릿"); + } + + @Test + @DisplayName("정상 키워드는 그대로 유지된다") + void normalKeyword_remainsAsIs() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of("오페라의 유령", null, pageable); + + // then + assertThat(condition.keyword()).isEqualTo("오페라의 유령"); + } + } + + @Nested + @DisplayName("sort 기본값 테스트") + class SortDefaultTest { + + @Test + @DisplayName("sort가 null이면 LATEST로 기본 설정된다") + void nullSort_defaultsToLatest() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of("햄릿", null, pageable); + + // then + assertThat(condition.sort()).isEqualTo(PerformanceSearchSort.LATEST); + } + + @Test + @DisplayName("sort가 지정되면 해당 값이 유지된다") + void specifiedSort_remainsAsIs() { + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of("햄릿", PerformanceSearchSort.HIT_DESC, pageable); + + // then + assertThat(condition.sort()).isEqualTo(PerformanceSearchSort.HIT_DESC); + } + } + + @Test + @DisplayName("pageable이 그대로 전달된다") + void pageable_isPassedThrough() { + // given + Pageable customPageable = PageRequest.of(2, 16); + + // when + PerformanceSearchCondition condition = PerformanceSearchCondition.of("햄릿", PerformanceSearchSort.DATE_ASC, customPageable); + + // then + assertThat(condition.pageable().getPageNumber()).isEqualTo(2); + assertThat(condition.pageable().getPageSize()).isEqualTo(16); + } +} diff --git a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapterTest.java b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapterTest.java new file mode 100644 index 00000000..8a6e7fdb --- /dev/null +++ b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/repository/performance/QueryDslPerformanceSearchAdapterTest.java @@ -0,0 +1,436 @@ +package wisoft.nextframe.schedulereservationticketing.repository.performance; + +import static org.assertj.core.api.Assertions.*; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.ActiveProfiles; + +import wisoft.nextframe.schedulereservationticketing.builder.PerformanceBuilder; +import wisoft.nextframe.schedulereservationticketing.builder.PerformanceStatisticBuilder; +import wisoft.nextframe.schedulereservationticketing.builder.ScheduleBuilder; +import wisoft.nextframe.schedulereservationticketing.builder.StadiumBuilder; +import wisoft.nextframe.schedulereservationticketing.config.TestContainersConfig; +import wisoft.nextframe.schedulereservationticketing.config.db.QueryDslConfig; +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchCondition; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.entity.performance.Performance; +import wisoft.nextframe.schedulereservationticketing.entity.stadium.Stadium; +import wisoft.nextframe.schedulereservationticketing.repository.schedule.ScheduleRepository; +import wisoft.nextframe.schedulereservationticketing.repository.stadium.StadiumRepository; + +@DataJpaTest +@ActiveProfiles("test") +@Import({TestContainersConfig.class, QueryDslConfig.class, QueryDslPerformanceSearchAdapter.class}) +@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) +class QueryDslPerformanceSearchAdapterTest { + + @Autowired + private QueryDslPerformanceSearchAdapter searchAdapter; + + @Autowired + private PerformanceRepository performanceRepository; + @Autowired + private ScheduleRepository scheduleRepository; + @Autowired + private StadiumRepository stadiumRepository; + @Autowired + private PerformanceStatisticRepository performanceStatisticRepository; + + private Stadium stadium; + private Pageable pageable; + private final LocalDateTime now = LocalDateTime.now(); + + @BeforeEach + void setUp() { + stadium = stadiumRepository.save(StadiumBuilder.builder().build()); + pageable = PageRequest.of(0, 32); + } + + @Nested + @DisplayName("키워드 검색 테스트") + class KeywordSearchTest { + + @Test + @DisplayName("키워드로 공연명을 검색하면 일치하는 공연만 조회된다") + void search_withKeyword_returnsMatchingPerformances() { + // given + createReservablePerformance("햄릿"); + createReservablePerformance("오페라의 유령"); + createReservablePerformance("맥베스"); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of("햄릿", PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().name()).isEqualTo("햄릿"); + } + + @Test + @DisplayName("키워드가 공연명의 일부와 일치하면 조회된다 (부분 일치)") + void search_withPartialKeyword_returnsMatchingPerformances() { + // given + createReservablePerformance("오페라의 유령"); + createReservablePerformance("오페라 갈라쇼"); + createReservablePerformance("맥베스"); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of("오페라", PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactlyInAnyOrder("오페라의 유령", "오페라 갈라쇼"); + } + + @Test + @DisplayName("키워드 검색은 대소문자를 구분하지 않는다") + void search_caseInsensitive() { + // given + createReservablePerformance("Hamlet"); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of("hamlet", PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().name()).isEqualTo("Hamlet"); + } + + @Test + @DisplayName("키워드가 null이면 전체 예매 가능한 공연이 조회된다") + void search_withNullKeyword_returnsAllReservablePerformances() { + // given + createReservablePerformance("햄릿"); + createReservablePerformance("오페라의 유령"); + createReservablePerformance("맥베스"); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(3); + } + + @Test + @DisplayName("일치하는 공연이 없으면 빈 결과를 반환한다") + void search_noMatch_returnsEmptyPage() { + // given + createReservablePerformance("햄릿"); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of("존재하지않는공연", PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + } + } + + @Nested + @DisplayName("예매 필터 테스트") + class ReservationFilterTest { + + @Test + @DisplayName("예매 기간이 아닌 공연은 검색 결과에서 제외된다") + void search_excludesNonReservablePerformances() { + // given + // 예매 가능한 공연 + createReservablePerformance("예매 가능 공연"); + + // 예매 시작 전 공연 + Performance notYetOpen = performanceRepository.save( + PerformanceBuilder.builder().withName("예매 시작 전 공연").build() + ); + scheduleRepository.save(ScheduleBuilder.builder() + .withPerformance(notYetOpen) + .withStadium(stadium) + .withTicketOpenTime(now.plusDays(10)) + .withTicketCloseTime(now.plusDays(30)) + .build()); + + // 예매 마감된 공연 + Performance closed = performanceRepository.save( + PerformanceBuilder.builder().withName("예매 마감 공연").build() + ); + scheduleRepository.save(ScheduleBuilder.builder() + .withPerformance(closed) + .withStadium(stadium) + .withTicketOpenTime(now.minusDays(30)) + .withTicketCloseTime(now.minusDays(1)) + .build()); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().getFirst().name()).isEqualTo("예매 가능 공연"); + } + } + + @Nested + @DisplayName("정렬 테스트") + class SortTest { + + @Test + @DisplayName("LATEST 정렬: 공연 시작일이 최신인 순서로 조회된다") + void search_sortByLatest() { + // given + createReservablePerformanceWithDate("오래된 공연", now.plusDays(5)); + createReservablePerformanceWithDate("최신 공연", now.plusDays(30)); + createReservablePerformanceWithDate("중간 공연", now.plusDays(15)); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.LATEST, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(3); + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("최신 공연", "중간 공연", "오래된 공연"); + } + + @Test + @DisplayName("DATE_ASC 정렬: 공연 시작일 오름차순으로 조회된다") + void search_sortByDateAsc() { + // given + createReservablePerformanceWithDate("늦은 공연", now.plusDays(30)); + createReservablePerformanceWithDate("빠른 공연", now.plusDays(5)); + createReservablePerformanceWithDate("중간 공연", now.plusDays(15)); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.DATE_ASC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("빠른 공연", "중간 공연", "늦은 공연"); + } + + @Test + @DisplayName("DATE_DESC 정렬: 공연 시작일 내림차순으로 조회된다") + void search_sortByDateDesc() { + // given + createReservablePerformanceWithDate("빠른 공연", now.plusDays(5)); + createReservablePerformanceWithDate("늦은 공연", now.plusDays(30)); + createReservablePerformanceWithDate("중간 공연", now.plusDays(15)); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.DATE_DESC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("늦은 공연", "중간 공연", "빠른 공연"); + } + + @Test + @DisplayName("HIT_DESC 정렬: 조회수 내림차순으로 조회된다") + void search_sortByHitDesc() { + // given + createReservablePerformanceWithHit("인기 공연", 1000); + createReservablePerformanceWithHit("보통 공연", 500); + createReservablePerformanceWithHit("비인기 공연", 100); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.HIT_DESC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("인기 공연", "보통 공연", "비인기 공연"); + } + + @Test + @DisplayName("STAR_DESC 정렬: 평균 별점 내림차순으로 조회된다") + void search_sortByStarDesc() { + // given + createReservablePerformanceWithStar("별점 높은 공연", new BigDecimal("4.5")); + createReservablePerformanceWithStar("별점 중간 공연", new BigDecimal("3.0")); + createReservablePerformanceWithStar("별점 낮은 공연", new BigDecimal("1.5")); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.STAR_DESC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("별점 높은 공연", "별점 중간 공연", "별점 낮은 공연"); + } + + @Test + @DisplayName("HIT_DESC 정렬: 조회수 동점 시 시작일 오름차순으로 2차 정렬된다") + void search_sortByHitDesc_tieBreakByDateAsc() { + // given + createReservablePerformanceWithHitAndDate("늦은 동점 공연", 500, now.plusDays(20)); + createReservablePerformanceWithHitAndDate("빠른 동점 공연", 500, now.plusDays(5)); + createReservablePerformanceWithHitAndDate("1위 공연", 1000, now.plusDays(10)); + + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.HIT_DESC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("1위 공연", "빠른 동점 공연", "늦은 동점 공연"); + } + } + + @Nested + @DisplayName("페이지네이션 테스트") + class PaginationTest { + + @Test + @DisplayName("페이지 크기에 맞게 결과가 분할된다") + void search_paginationWorks() { + // given + for (int i = 0; i < 5; i++) { + createReservablePerformance("공연 " + i); + } + + Pageable firstPage = PageRequest.of(0, 2); + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.LATEST, firstPage); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getTotalElements()).isEqualTo(5); + assertThat(result.getTotalPages()).isEqualTo(3); + assertThat(result.hasNext()).isTrue(); + } + + @Test + @DisplayName("두 번째 페이지를 정확히 조회한다") + void search_secondPage() { + // given + for (int i = 0; i < 5; i++) { + createReservablePerformanceWithDate("공연 " + i, now.plusDays(30 - i)); + } + + Pageable secondPage = PageRequest.of(1, 2); + PerformanceSearchCondition condition = PerformanceSearchCondition.of(null, PerformanceSearchSort.LATEST, secondPage); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getNumber()).isEqualTo(1); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isTrue(); + } + } + + @Nested + @DisplayName("키워드 + 정렬 복합 테스트") + class CombinedSearchTest { + + @Test + @DisplayName("키워드 검색과 조회수 정렬을 동시에 적용한다") + void search_withKeywordAndHitSort() { + // given + createReservablePerformanceWithHit("뮤지컬 캣츠", 100); + createReservablePerformanceWithHit("뮤지컬 위키드", 500); + createReservablePerformanceWithHit("연극 햄릿", 1000); // 키워드 불일치 + + PerformanceSearchCondition condition = PerformanceSearchCondition.of("뮤지컬", PerformanceSearchSort.HIT_DESC, pageable); + + // when + Page result = searchAdapter.search(condition); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()) + .extracting(PerformanceSummaryResponse::name) + .containsExactly("뮤지컬 위키드", "뮤지컬 캣츠"); + } + } + + // === 헬퍼 메서드 === + + private Performance createReservablePerformance(String name) { + return createReservablePerformanceWithDate(name, now.plusDays(20)); + } + + private Performance createReservablePerformanceWithDate(String name, LocalDateTime performanceDatetime) { + Performance performance = performanceRepository.save( + PerformanceBuilder.builder().withName(name).build() + ); + + scheduleRepository.save(ScheduleBuilder.builder() + .withPerformance(performance) + .withStadium(stadium) + .withPerformanceDatetime(performanceDatetime) + .build()); + + return performance; + } + + private void createReservablePerformanceWithHit(String name, int hit) { + createReservablePerformanceWithHitAndDate(name, hit, now.plusDays(20)); + } + + private void createReservablePerformanceWithHitAndDate(String name, int hit, LocalDateTime performanceDatetime) { + Performance performance = createReservablePerformanceWithDate(name, performanceDatetime); + + performanceStatisticRepository.save( + PerformanceStatisticBuilder.builder() + .withPerformance(performance) + .withHit(hit) + .build() + ); + } + + private void createReservablePerformanceWithStar(String name, BigDecimal averageStar) { + Performance performance = createReservablePerformance(name); + + performanceStatisticRepository.save( + PerformanceStatisticBuilder.builder() + .withPerformance(performance) + .withAverageStar(averageStar) + .build() + ); + } +} diff --git a/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceServiceSearchTest.java b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceServiceSearchTest.java new file mode 100644 index 00000000..ad5a5b6c --- /dev/null +++ b/schedule-reservation-ticketing/src/test/java/wisoft/nextframe/schedulereservationticketing/service/performance/PerformanceServiceSearchTest.java @@ -0,0 +1,158 @@ +package wisoft.nextframe.schedulereservationticketing.service.performance; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.List; + +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.ArgumentCaptor; +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 org.springframework.data.domain.Pageable; + +import wisoft.nextframe.schedulereservationticketing.dto.performance.performancelist.response.PerformanceSummaryResponse; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchCondition; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.request.PerformanceSearchSort; +import wisoft.nextframe.schedulereservationticketing.dto.performance.search.response.PerformanceSearchResponse; +import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformancePricingRepository; +import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceRepository; +import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceSearchPort; +import wisoft.nextframe.schedulereservationticketing.repository.performance.PerformanceStatisticRepository; +import wisoft.nextframe.schedulereservationticketing.repository.schedule.ScheduleRepository; + +@ExtendWith(MockitoExtension.class) +class PerformanceServiceSearchTest { + + @InjectMocks + private PerformanceService performanceService; + + @Mock + private PerformanceRepository performanceRepository; + @Mock + private PerformanceSearchPort performanceSearchPort; + @Mock + private ScheduleRepository scheduleRepository; + @Mock + private PerformancePricingRepository performancePricingRepository; + @Mock + private PerformanceStatisticRepository performanceStatisticRepository; + + @Nested + @DisplayName("searchPerformances 테스트") + class SearchPerformancesTest { + + @Test + @DisplayName("키워드와 정렬 기준으로 공연을 검색한다") + void searchPerformances_withKeywordAndSort() { + // given + String keyword = "햄릿"; + PerformanceSearchSort sort = PerformanceSearchSort.HIT_DESC; + Pageable pageable = PageRequest.of(0, 32); + + PerformanceSummaryResponse summaryDto = mock(PerformanceSummaryResponse.class); + Page mockPage = new PageImpl<>(List.of(summaryDto), pageable, 1); + + given(performanceSearchPort.search(any(PerformanceSearchCondition.class))) + .willReturn(mockPage); + + // when + PerformanceSearchResponse result = performanceService.searchPerformances(keyword, sort, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.keyword()).isEqualTo("햄릿"); + assertThat(result.performances()).hasSize(1); + assertThat(result.pagination().totalItems()).isEqualTo(1); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(PerformanceSearchCondition.class); + verify(performanceSearchPort).search(conditionCaptor.capture()); + + PerformanceSearchCondition captured = conditionCaptor.getValue(); + assertThat(captured.keyword()).isEqualTo("햄릿"); + assertThat(captured.sort()).isEqualTo(PerformanceSearchSort.HIT_DESC); + assertThat(captured.pageable()).isEqualTo(pageable); + } + + @Test + @DisplayName("키워드 없이 검색하면 전체 조회된다") + void searchPerformances_withoutKeyword() { + // given + Pageable pageable = PageRequest.of(0, 32); + + PerformanceSummaryResponse summaryDto1 = mock(PerformanceSummaryResponse.class); + PerformanceSummaryResponse summaryDto2 = mock(PerformanceSummaryResponse.class); + Page mockPage = new PageImpl<>(List.of(summaryDto1, summaryDto2), pageable, 2); + + given(performanceSearchPort.search(any(PerformanceSearchCondition.class))) + .willReturn(mockPage); + + // when + PerformanceSearchResponse result = performanceService.searchPerformances(null, null, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.keyword()).isNull(); + assertThat(result.performances()).hasSize(2); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(PerformanceSearchCondition.class); + verify(performanceSearchPort).search(conditionCaptor.capture()); + + PerformanceSearchCondition captured = conditionCaptor.getValue(); + assertThat(captured.keyword()).isNull(); + assertThat(captured.sort()).isEqualTo(PerformanceSearchSort.LATEST); + } + + @Test + @DisplayName("검색 결과가 없으면 빈 목록을 반환한다") + void searchPerformances_emptyResult() { + // given + Pageable pageable = PageRequest.of(0, 32); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + given(performanceSearchPort.search(any(PerformanceSearchCondition.class))) + .willReturn(emptyPage); + + // when + PerformanceSearchResponse result = performanceService.searchPerformances("존재하지않는공연", null, pageable); + + // then + assertThat(result).isNotNull(); + assertThat(result.keyword()).isEqualTo("존재하지않는공연"); + assertThat(result.performances()).isEmpty(); + assertThat(result.pagination().totalItems()).isZero(); + } + + @Test + @DisplayName("공백 키워드는 null로 정규화되어 전달된다") + void searchPerformances_blankKeywordNormalized() { + // given + Pageable pageable = PageRequest.of(0, 32); + Page mockPage = new PageImpl<>(List.of(), pageable, 0); + + given(performanceSearchPort.search(any(PerformanceSearchCondition.class))) + .willReturn(mockPage); + + // when + PerformanceSearchResponse result = performanceService.searchPerformances(" ", null, pageable); + + // then + assertThat(result.keyword()).isNull(); + + ArgumentCaptor conditionCaptor = + ArgumentCaptor.forClass(PerformanceSearchCondition.class); + verify(performanceSearchPort).search(conditionCaptor.capture()); + + assertThat(conditionCaptor.getValue().keyword()).isNull(); + } + } +}