Files
HeuJung 3ebbda5d86 feat(board,ecommerce,pay,verification,templates,core): 문의 답변 사슬·답글 삭제 정책·검증 계약 + 실패 사유 표면화 전수
공개 (답변 중복 등록)/(문의 삭제 시 답변 잔존) 수정과 동형 결함 전수 해소.

- 문의 답변 단일성: 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파일)
2026-08-17 17:04:44 +09:00

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'],
]
);
}
}
}