공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.
- 문의 답변 단일성: 1차(피벗 is_answered) + 2차(게시판 리스너 duplicate 마커) 이중 방어,
경합 경로에서도 사유(reply_already_exists 422)가 보존되도록 훅 반환 계약 확장
- 질문·답변·피벗 생명주기 대칭: 삭제/복원/영구삭제 훅 연동 + 고아 데이터 백필 업그레이드 스텝
- 게시판별 답글 삭제 정책(cascade/block) 신설, 정렬 화이트리스트·per_page 게이트
(전량 조회 소비처 200/999 를 수용하는 상한 999 — 조였다가 관리자 화면 2곳 422 회귀 즉시 정정)
- 결제/본인인증 플러그인 검증 계약: FormRequest 15종 승격 + 컨트롤러 catch 정합.
거래 조회 tid 상한은 저장 컬럼(varchar 100) 기준 — 해외 CBT TID(40자) 수용
- onError 토스트 고정 문구 전수 해소(67건/50파일/15확장) — 서버 실패 사유를 그대로 표면화,
change-password 오변수(response→error) 교정
- 코어 플러그인 설정 검증 안내의 항목 표시명이 스키마 라벨(ko/en) 폴백에 덮여 요청 로케일
(ja) 라벨을 잃던 회귀 정정 — 번역 등록이 있으면 그것을 우선 (7.0.7 미출시 기능 자체 결함)
- 사전 결함 정리(Icon 등가표 27·darkmode 2·validated 통일), API 문서·CHANGELOG·ja 팩 동기
- develop(60652fdc0) 리베이스: getTable 정비와 병합 — 양측 의도 보존 (충돌 18파일)
295 lines
11 KiB
PHP
295 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Plugins\Sirsoft\PayNicepayments\Tests\Feature\Controllers;
|
|
|
|
use App\Models\User;
|
|
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
|
|
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\SequenceType;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
|
|
use Modules\Sirsoft\Ecommerce\Models\OrderRefund;
|
|
use Modules\Sirsoft\Ecommerce\Models\Sequence;
|
|
use Plugins\Sirsoft\PayNicepayments\Services\NicePaymentsApiService;
|
|
use Plugins\Sirsoft\PayNicepayments\Tests\PluginTestCase;
|
|
|
|
class AdminVbankRefundControllerTest extends PluginTestCase
|
|
{
|
|
private const TID = 'NICE_VBANK_TID_001';
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->createCancelSequences();
|
|
}
|
|
|
|
public function test_admin_vbank_refund_updates_order_domain_after_pg_cancel(): void
|
|
{
|
|
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
|
$order = $this->createPaidVbankOrder();
|
|
|
|
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
|
|
$apiServiceMock->expects($this->once())
|
|
->method('useStoredCredentials')
|
|
->with(false, 'SRLIVE001');
|
|
$apiServiceMock->expects($this->once())
|
|
->method('cancelPayment')
|
|
->with(
|
|
self::TID,
|
|
$order->order_number,
|
|
30000,
|
|
'가상계좌 환불',
|
|
0,
|
|
'1234567890',
|
|
'004',
|
|
'홍길동',
|
|
)
|
|
->willReturn([
|
|
'ResultCode' => '2001',
|
|
'ResultMsg' => '취소 성공',
|
|
'TID' => self::TID,
|
|
'CancelAmt' => '30000',
|
|
]);
|
|
|
|
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
|
|
|
|
$response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [
|
|
'tid' => self::TID,
|
|
'moid' => $order->order_number,
|
|
'cancel_amt' => 30000,
|
|
'cancel_msg' => '가상계좌 환불',
|
|
'refund_acct_no' => '1234567890',
|
|
'refund_bank_cd' => '004',
|
|
'refund_acct_nm' => '홍길동',
|
|
]);
|
|
|
|
$response->assertOk()->assertJsonPath('success', true);
|
|
|
|
$order->refresh();
|
|
$payment = $order->payment->fresh();
|
|
$refund = OrderRefund::query()->where('order_id', $order->id)->first();
|
|
|
|
$this->assertEquals(OrderStatusEnum::CANCELLED, $order->order_status);
|
|
$this->assertEquals(PaymentStatusEnum::CANCELLED, $payment->payment_status);
|
|
$this->assertEquals(30000.0, (float) $payment->cancelled_amount);
|
|
$this->assertSame('completed', $payment->payment_meta['vbank_refund_status'] ?? null);
|
|
$this->assertNotNull($refund);
|
|
$this->assertEquals(30000.0, (float) $refund->refund_amount);
|
|
}
|
|
|
|
public function test_admin_vbank_refund_rejects_amount_mismatch_before_pg_cancel(): void
|
|
{
|
|
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
|
$order = $this->createPaidVbankOrder();
|
|
|
|
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
|
|
$apiServiceMock->expects($this->never())->method('cancelPayment');
|
|
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
|
|
|
|
$response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [
|
|
'tid' => self::TID,
|
|
'moid' => $order->order_number,
|
|
'cancel_amt' => 29999,
|
|
'refund_acct_no' => '1234567890',
|
|
'refund_bank_cd' => '004',
|
|
'refund_acct_nm' => '홍길동',
|
|
]);
|
|
|
|
$response->assertStatus(422)->assertJsonPath('success', false);
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
$this->assertEquals(0.0, (float) $order->payment->cancelled_amount);
|
|
}
|
|
|
|
public function test_admin_vbank_refund_requires_orders_update_permission(): void
|
|
{
|
|
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.read']);
|
|
$order = $this->createPaidVbankOrder();
|
|
|
|
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
|
|
$apiServiceMock->expects($this->never())->method('cancelPayment');
|
|
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
|
|
|
|
$response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [
|
|
'tid' => self::TID,
|
|
'moid' => $order->order_number,
|
|
'cancel_amt' => 30000,
|
|
'refund_acct_no' => '1234567890',
|
|
'refund_bank_cd' => '004',
|
|
'refund_acct_nm' => '홍길동',
|
|
]);
|
|
|
|
$response->assertForbidden();
|
|
}
|
|
|
|
/**
|
|
* 환불 계좌 형식 위반 4종(계좌번호 17자리 / 은행코드 비숫자 / 예금주 11자 / 취소사유 101자)은
|
|
* VbankRefundRequest 가 표준 422 로 차단하고 PG 취소 API 는 호출되지 않아야 한다.
|
|
*/
|
|
public function test_admin_vbank_refund_rejects_malformed_refund_account_fields(): void
|
|
{
|
|
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
|
$order = $this->createPaidVbankOrder();
|
|
|
|
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
|
|
$apiServiceMock->expects($this->never())->method('useStoredCredentials');
|
|
$apiServiceMock->expects($this->never())->method('cancelPayment');
|
|
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
|
|
|
|
$response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [
|
|
'tid' => self::TID,
|
|
'moid' => $order->order_number,
|
|
'cancel_amt' => 30000,
|
|
'cancel_msg' => str_repeat('가', 101),
|
|
'refund_acct_no' => str_repeat('1', 17),
|
|
'refund_bank_cd' => '00A',
|
|
'refund_acct_nm' => str_repeat('가', 11),
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors([
|
|
'cancel_msg',
|
|
'refund_acct_no',
|
|
'refund_bank_cd',
|
|
'refund_acct_nm',
|
|
]);
|
|
|
|
$order->refresh();
|
|
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status);
|
|
$this->assertEquals(0.0, (float) $order->payment->cancelled_amount);
|
|
}
|
|
|
|
/**
|
|
* 배열 주입(`tid[]=x`)은 string 규칙이 422 로 차단해야 한다 — 종전 수동 검사는
|
|
* 문자열 캐스팅 전제라 배열 입력이 형식 검증을 우회할 수 있었다.
|
|
*/
|
|
public function test_admin_vbank_refund_rejects_array_injection(): void
|
|
{
|
|
$admin = $this->createAdminUser(['sirsoft-ecommerce.orders.update']);
|
|
$order = $this->createPaidVbankOrder();
|
|
|
|
$apiServiceMock = $this->createMock(NicePaymentsApiService::class);
|
|
$apiServiceMock->expects($this->never())->method('cancelPayment');
|
|
$this->app->instance(NicePaymentsApiService::class, $apiServiceMock);
|
|
|
|
$response = $this->actingAs($admin)->postJson('/api/plugins/sirsoft-pay_nicepayments/admin/vbank-refund', [
|
|
'tid' => ['x'],
|
|
'moid' => $order->order_number,
|
|
'cancel_amt' => 30000,
|
|
'refund_acct_no' => '1234567890',
|
|
'refund_bank_cd' => '004',
|
|
'refund_acct_nm' => '홍길동',
|
|
]);
|
|
|
|
$response->assertStatus(422)
|
|
->assertJsonValidationErrors(['tid']);
|
|
}
|
|
|
|
private function createPaidVbankOrder(): Order
|
|
{
|
|
$user = User::factory()->create();
|
|
|
|
$order = Order::factory()->create([
|
|
'user_id' => $user->id,
|
|
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'subtotal_amount' => 30000,
|
|
'total_shipping_amount' => 0,
|
|
'total_amount' => 30000,
|
|
'total_paid_amount' => 30000,
|
|
'total_due_amount' => 0,
|
|
'total_cancelled_amount' => 0,
|
|
'cancellation_count' => 0,
|
|
'paid_at' => now(),
|
|
'promotions_applied_snapshot' => [],
|
|
'shipping_policy_applied_snapshot' => [],
|
|
]);
|
|
|
|
OrderOption::factory()->forOrder($order)->create([
|
|
'quantity' => 1,
|
|
'unit_price' => 30000,
|
|
'subtotal_price' => 30000,
|
|
'subtotal_paid_amount' => 30000,
|
|
'subtotal_discount_amount' => 0,
|
|
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
|
|
'product_snapshot' => [
|
|
'id' => null,
|
|
'name' => ['ko' => '테스트상품', 'en' => 'Test Product'],
|
|
'product_code' => null,
|
|
'sku' => null,
|
|
'brand_id' => null,
|
|
'list_price' => 30000,
|
|
'selling_price' => 30000,
|
|
'currency_code' => 'KRW',
|
|
'stock_quantity' => 100,
|
|
'tax_status' => 'taxable',
|
|
'tax_rate' => 10,
|
|
'has_options' => false,
|
|
'option_groups' => null,
|
|
'thumbnail_url' => null,
|
|
],
|
|
'option_snapshot' => [
|
|
'id' => null,
|
|
'option_code' => null,
|
|
'option_values' => null,
|
|
'option_name' => '기본',
|
|
'price_adjustment' => 0,
|
|
'list_price' => 30000,
|
|
'selling_price' => 30000,
|
|
'currency_code' => 'KRW',
|
|
'stock_quantity' => 100,
|
|
'weight' => 0.5,
|
|
'volume' => 0.01,
|
|
'sku' => null,
|
|
'is_default' => true,
|
|
],
|
|
]);
|
|
|
|
OrderPaymentFactory::new()->forOrder($order)->create([
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'payment_method' => PaymentMethodEnum::VBANK,
|
|
'pg_provider' => 'nicepayments',
|
|
'paid_amount_local' => 30000,
|
|
'paid_amount_base' => 30000,
|
|
'cancelled_amount' => 0,
|
|
'paid_at' => now(),
|
|
'transaction_id' => self::TID,
|
|
'vbank_number' => '0987654321',
|
|
'vbank_name' => '국민은행',
|
|
'payment_meta' => [
|
|
'mid' => 'SRLIVE001',
|
|
'is_test_mode' => false,
|
|
'vbank_tid' => self::TID,
|
|
'vbank_num' => '0987654321',
|
|
],
|
|
]);
|
|
|
|
return $order->fresh(['payment', 'options', 'shippings']);
|
|
}
|
|
|
|
private function createCancelSequences(): void
|
|
{
|
|
foreach ([SequenceType::CANCEL, SequenceType::REFUND] as $type) {
|
|
$cfg = $type->getDefaultConfig();
|
|
Sequence::firstOrCreate(
|
|
['type' => $type->value],
|
|
[
|
|
'algorithm' => $cfg['algorithm']->value,
|
|
'prefix' => $cfg['prefix'],
|
|
'current_value' => 0,
|
|
'increment' => 1,
|
|
'min_value' => 1,
|
|
'max_value' => $cfg['max_value'],
|
|
'cycle' => false,
|
|
'pad_length' => $cfg['pad_length'],
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|