Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
13 commits
Select commit Hold shift + click to select a range
be85c65
#164 feat: ํ™˜๋ถˆ API ๊ตฌํ˜„ ๋ฐ payment-gateway ํ™˜๋ถˆ ์—ฐ๋™
git-mesome Feb 14, 2026
528911f
#164 feat: TossPaymentProvider์— Resilience4j ๋ณต์›๋ ฅ ํŒจํ„ด ์ ์šฉ
git-mesome Feb 16, 2026
543c7ca
#164 feat: HttpPaymentGatewayAdapter์— ํƒ€์ž„์•„์›ƒ ๋ฐ CircuitBreaker ์ ์šฉ
git-mesome Feb 16, 2026
1aeade0
#35 feat: ๊ณต์—ฐ ๊ฒ€์ƒ‰ API ๊ตฌํ˜„ (Port/Adapter ํŒจํ„ด)
git-mesome Feb 16, 2026
028d3a7
#173 refactor: ํ‹ฐ์ผ“ ๋ฐœ๊ธ‰์„ Outbox ๋น„๋™๊ธฐ์—์„œ ๋™๊ธฐ ํ”Œ๋กœ์šฐ๋กœ ๋ณ€๊ฒฝ
git-mesome Feb 17, 2026
2e4422f
ci: Testcontainers Ryuk ๋น„ํ™œ์„ฑํ™”๋กœ CI Docker ๊ถŒํ•œ ๋ฌธ์ œ ํ•ด๊ฒฐ
git-mesome Feb 17, 2026
d8390a5
ci:runs-on ์šฐ๋ถ„ํˆฌ ๋ฒ„์ „ ์ง€์ • latest > 22.04
git-mesome Feb 17, 2026
5a36b49
fix:test ํ”„๋กœํ•„ ์ง€์ •
git-mesome Feb 17, 2026
e557cfb
ci:docker ์ง„๋‹จ ์ถ”๊ฐ€
git-mesome Feb 17, 2026
96c9f7f
ci:docker sock ์•ก์…˜ ์ถ”๊ฐ€
git-mesome Feb 17, 2026
8f689f0
ci:testcontainer version up
git-mesome Feb 17, 2026
105e722
fix:testcontainer ์ตœ์†Œ version ์ง€์ •
git-mesome Feb 17, 2026
1080027
fix:reusable-build.yml docker-java API version ๊ฐ•์ œ ํ‘ธ์‹œ
git-mesome Feb 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions .github/workflows/reusable-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ on:

jobs:
build:
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
steps:
- name: ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ฒดํฌ์•„์›ƒ
uses: actions/checkout@v4
Expand Down Expand Up @@ -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 }}
Expand Down
6 changes: 4 additions & 2 deletions payment-gateway/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,27 @@ public ResponseEntity<ConfirmResponse> confirm(
return response;
}

@PostMapping("/payments/cancel")
public ResponseEntity<CancelResponse> cancel(
Comment on lines +33 to +34
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The cancel endpoint does not perform any authorization checks on the orderId provided in the request body. This allows any caller with access to the gateway service to cancel any payment that has been previously confirmed and stored in the instance's memory. If this service is exposed or accessible from other compromised internal services, it poses a high risk of unauthorized payment cancellations.

@RequestParam(defaultValue = "toss") String provider,
@RequestBody CancelRequest request
) {
log.info("[Gateway] cancel ์š”์ฒญ ์ˆ˜์‹  - provider={}, request={}", provider, request);
ResponseEntity<CancelResponse> 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) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ public interface PaymentProvider {
boolean supports(String providerName);

ConfirmResponse confirm(ConfirmRequest request);

CancelResponse cancel(CancelRequest request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,17 @@ public ProviderRouter(List<PaymentProvider> 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("์ง€์›ํ•˜์ง€ ์•Š๋Š” ๊ฒฐ์ œ์ˆ˜๋‹จ์ž…๋‹ˆ๋‹ค."));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> paymentKeyStore = new ConcurrentHashMap<>();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-medium medium

The paymentKeyStore is an unbounded ConcurrentHashMap that stores payment keys indefinitely. This design leads to a continuous memory leak, potentially causing an OutOfMemoryError and Denial of Service (DoS). In a multi-instance production environment, this also results in data loss upon application restart, making refund functionality unreliable. This implementation is unsafe for production use and must be replaced with a persistent and distributed storage solution like a database or Redis.


Comment on lines 30 to 32
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

paymentKeyStore is in-memory only. In a multi-instance deployment or after a restart, the gateway wonโ€™t find the paymentKey for an orderId, and refunds will fail with PAYMENT_KEY_NOT_FOUND. Persist this mapping (DB/Redis) or redesign cancel so it doesnโ€™t depend on per-instance memory (e.g., store paymentKey in the payment service DB and pass it through).

Copilot uses AI. Check for mistakes.
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();
}

Expand All @@ -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")
Expand All @@ -52,11 +74,61 @@ 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);
}
String code = (String)response.getOrDefault("code", "TOSS_ERROR");
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) {
Comment on lines +101 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

confirm ๋ฉ”์„œ๋“œ๋Š” @CircuitBreaker์™€ @Bulkhead๋กœ ์ž˜ ๋ณดํ˜ธ๋˜์–ด ์žˆ์ง€๋งŒ, cancel ๋ฉ”์„œ๋“œ์—๋Š” ์ด๋Ÿฌํ•œ ์žฅ์•  ๋‚ด์„ฑ ํŒจํ„ด์ด ์ ์šฉ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ทจ์†Œ๋ฅผ ํฌํ•จํ•œ ๋ชจ๋“  ์™ธ๋ถ€ API ํ˜ธ์ถœ์€ ์‹คํŒจํ•  ๊ฐ€๋Šฅ์„ฑ์ด ์žˆ์œผ๋ฏ€๋กœ, ์žฅ์•  ์ „ํŒŒ๋ฅผ ๋ง‰๊ธฐ ์œ„ํ•ด confirm ๋ฉ”์„œ๋“œ์™€ ์œ ์‚ฌํ•˜๊ฒŒ ๋ณดํ˜ธํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. cancel ๋ฉ”์„œ๋“œ์—๋„ ๋™์ผํ•œ ์žฅ์•  ๋‚ด์„ฑ ํŒจํ„ด์„ ์ ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค. (์ ์šฉ ํ›„์—๋Š” ํ•ด๋‹น fallback ๋ฉ”์„œ๋“œ๋“ค๋„ ์ถ”๊ฐ€ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.)

	@Override
	@CircuitBreaker(name = "tossCancel", fallbackMethod = "cancelFallback")
	@Bulkhead(name = "tossCancel")
	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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Toss API์˜ JSON ์‘๋‹ต์„ Map.class๋กœ ์—ญ์ง๋ ฌํ™”ํ•˜๋Š” ๊ฒƒ์€ ํƒ€์ž…์— ์•ˆ์ „ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ์‘๋‹ต ๊ตฌ์กฐ๊ฐ€ ๋ณ€๊ฒฝ๋˜๊ฑฐ๋‚˜ ์˜ˆ์ƒ์น˜ ๋ชปํ•œ ํƒ€์ž…์ด ํฌํ•จ๋  ๊ฒฝ์šฐ ClassCastException๊ณผ ๊ฐ™์€ ๋Ÿฐํƒ€์ž„ ์—๋Ÿฌ๋ฅผ ์œ ๋ฐœํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์˜ˆ์ƒ๋˜๋Š” ์‘๋‹ต ๊ตฌ์กฐ์— ๋งž๋Š” ์ „์šฉ record๋‚˜ ํด๋ž˜์Šค๋ฅผ ์ •์˜ํ•˜๋Š” ๊ฒƒ์ด ํ›จ์”ฌ ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” ์ปดํŒŒ์ผ ์‹œ์ ์˜ ํƒ€์ž… ์ฒดํฌ๋ฅผ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ•˜๊ณ  ์ฝ”๋“œ ๊ฐ€๋…์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ํ–ฅ์ƒ์‹œํ‚ต๋‹ˆ๋‹ค. ์ด ๋‚ด์šฉ์€ confirm ๋ฉ”์„œ๋“œ(73ํ–‰)์—๋„ ๋™์ผํ•˜๊ฒŒ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.


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());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
}
}
Loading