현금영수증 발급사를 PG 결제사와 독립적으로 선택할 수 있도록 공용 훅 축 (registered_providers/issue/cancel)에 토스·KG 프로바이더를 등록한다. KG 결제로 받으면서 영수증만 토스로 발급하는 구성이 가능해진다. KG 전용 발행 컨트롤러· 라우트·관리자 패널은 이 축으로 이전하며 제거했다 — 이중 발행 경로를 남기지 않기 위해서다. 토스 환불은 네 가지를 바로잡았다. 전액취소를 부분취소로 보내던 문제(R1), 면세금액 미전달로 복합과세 상점의 부가세가 틀어지던 문제(R2), 가상계좌 환불계좌를 싣지 않아 취소가 실패하던 문제(R3), 에스크로 부분취소를 PG 호출 전에 차단하지 않던 문제(E4). 취소·발급 요청에는 멱등키를 부착해 네트워크 재시도가 국세청에 이중 신고되거나 이중 환불되는 것을 막는다. 코어 활동로그 가드가 \Exception 만 잡아 채널 해석 실패(\Error)를 놓치고 있었다. 결제 입금통보 같은 외부 콜백이 로그 문제만으로 500을 반환하던 문제를 \Throwable 로 수정한다. 활동로그는 부수 기록이므로 그것을 유발한 요청까지 죽여서는 안 된다. 토스 API 서비스는 베이스 \Exception 을 직접 던지고 있어 도메인 예외 (TossPaymentsApiException)를 신설했다. KG의 KgInicisApiException 선례와 동형이며 RuntimeException 상속이라 기존 소비자의 catch 는 그대로 동작한다.
170 lines
5.8 KiB
PHP
170 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace Plugins\Sirsoft\Tosspayments\Tests\Unit\Services;
|
|
|
|
use App\Services\PluginSettingsService;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Plugins\Sirsoft\Tosspayments\Services\TossPaymentsApiService;
|
|
use Plugins\Sirsoft\Tosspayments\Tests\PluginTestCase;
|
|
|
|
/**
|
|
* TossPaymentsApiService 단위 테스트
|
|
*/
|
|
class TossPaymentsApiServiceTest extends PluginTestCase
|
|
{
|
|
/**
|
|
* PluginSettingsService mock을 생성합니다.
|
|
*
|
|
* @param array $settings 반환할 설정값
|
|
*/
|
|
private function mockSettingsService(array $settings): PluginSettingsService
|
|
{
|
|
$mock = $this->createMock(PluginSettingsService::class);
|
|
$mock->method('get')
|
|
->with('sirsoft-tosspayments')
|
|
->willReturn($settings);
|
|
|
|
return $mock;
|
|
}
|
|
|
|
/**
|
|
* 테스트 모드에서 테스트 시크릿 키를 사용하는지 확인
|
|
*/
|
|
public function test_constructor_uses_test_secret_key_in_test_mode(): void
|
|
{
|
|
$service = new TossPaymentsApiService($this->mockSettingsService([
|
|
'is_test_mode' => true,
|
|
'test_secret_key' => 'test_sk_abc123',
|
|
'live_secret_key' => 'live_sk_xyz789',
|
|
]));
|
|
|
|
$reflection = new \ReflectionClass($service);
|
|
$prop = $reflection->getProperty('secretKey');
|
|
$prop->setAccessible(true);
|
|
|
|
$this->assertEquals('test_sk_abc123', $prop->getValue($service));
|
|
}
|
|
|
|
/**
|
|
* 라이브 모드에서 라이브 시크릿 키를 사용하는지 확인
|
|
*/
|
|
public function test_constructor_uses_live_secret_key_in_live_mode(): void
|
|
{
|
|
$service = new TossPaymentsApiService($this->mockSettingsService([
|
|
'is_test_mode' => false,
|
|
'test_secret_key' => 'test_sk_abc123',
|
|
'live_secret_key' => 'live_sk_xyz789',
|
|
]));
|
|
|
|
$reflection = new \ReflectionClass($service);
|
|
$prop = $reflection->getProperty('secretKey');
|
|
$prop->setAccessible(true);
|
|
|
|
$this->assertEquals('live_sk_xyz789', $prop->getValue($service));
|
|
}
|
|
|
|
/**
|
|
* 설정이 비어있을 때 기본값(테스트 모드, 빈 키)으로 초기화되는지 확인
|
|
*/
|
|
public function test_constructor_defaults_to_test_mode_with_empty_key(): void
|
|
{
|
|
$service = new TossPaymentsApiService($this->mockSettingsService([]));
|
|
|
|
$reflection = new \ReflectionClass($service);
|
|
$prop = $reflection->getProperty('secretKey');
|
|
$prop->setAccessible(true);
|
|
|
|
$this->assertEquals('', $prop->getValue($service));
|
|
}
|
|
|
|
/**
|
|
* 플러그인 설정이 null일 때 기본값으로 초기화되는지 확인
|
|
*/
|
|
public function test_constructor_handles_null_settings_gracefully(): void
|
|
{
|
|
$mock = $this->createMock(PluginSettingsService::class);
|
|
$mock->method('get')
|
|
->with('sirsoft-tosspayments')
|
|
->willReturn(null);
|
|
|
|
$service = new TossPaymentsApiService($mock);
|
|
|
|
$reflection = new \ReflectionClass($service);
|
|
$prop = $reflection->getProperty('secretKey');
|
|
$prop->setAccessible(true);
|
|
|
|
$this->assertEquals('', $prop->getValue($service));
|
|
}
|
|
|
|
/**
|
|
* 현금영수증 API 호출용 서비스를 만듭니다.
|
|
*/
|
|
private function makeService(): TossPaymentsApiService
|
|
{
|
|
return new TossPaymentsApiService($this->mockSettingsService([
|
|
'test_mode' => true,
|
|
'test_secret_key' => 'test_sk_dummy',
|
|
]));
|
|
}
|
|
|
|
/**
|
|
* 발급은 토스 현금영수증 엔드포인트로 요청 본문을 그대로 보내야 합니다.
|
|
*/
|
|
public function test_issue_cash_receipt_posts_to_cash_receipts_endpoint(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1', 'issueNumber' => '123'], 200)]);
|
|
|
|
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1', 'amount' => 1000]);
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->url() === 'https://api.tosspayments.com/v1/cash-receipts'
|
|
&& $request->method() === 'POST'
|
|
&& $request->data()['orderId'] === 'ORD-1';
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 취소는 cancelReason 을 반드시 실어야 합니다.
|
|
*
|
|
* 토스의 필수 파라미터이며, 누락 시 400 INVALID_REQUEST 로 거부된다.
|
|
* Http::fake 는 본문을 검증하지 않으므로 "보냈다" 가 아니라 "필수 필드가 들어있다" 를 단언한다.
|
|
*/
|
|
public function test_cancel_cash_receipt_sends_cancel_reason(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['receiptKey' => 'c_RK_1'], 200)]);
|
|
|
|
$this->makeService()->cancelCashReceipt('RK_1', '금액 변경');
|
|
|
|
Http::assertSent(function ($request) {
|
|
return $request->url() === 'https://api.tosspayments.com/v1/cash-receipts/RK_1/cancel'
|
|
&& ! empty($request->data()['cancelReason']);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 멱등키를 넘기면 Idempotency-Key 헤더로 전송되어야 합니다.
|
|
*
|
|
* 이 헤더가 없으면 네트워크 재시도가 국세청에 이중 신고된다.
|
|
*/
|
|
public function test_idempotency_key_is_sent_as_header(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1'], 200)]);
|
|
|
|
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1'], 'ORD-1-cr1');
|
|
|
|
Http::assertSent(fn ($request) => $request->hasHeader('Idempotency-Key', 'ORD-1-cr1'));
|
|
}
|
|
|
|
/**
|
|
* 멱등키를 넘기지 않으면 Idempotency-Key 헤더가 붙지 않아야 합니다.
|
|
*/
|
|
public function test_idempotency_key_header_absent_when_not_provided(): void
|
|
{
|
|
Http::fake(['*' => Http::response(['receiptKey' => 'RK_1'], 200)]);
|
|
|
|
$this->makeService()->issueCashReceipt(['orderId' => 'ORD-1']);
|
|
|
|
Http::assertSent(fn ($request) => ! $request->hasHeader('Idempotency-Key'));
|
|
}
|
|
}
|