확장이 훅으로 등록한 카탈로그에서 고른 값은 그 확장이 사라져도 저장값으로 남는다. 그 상태에서 값만 보고 판정하면 이미 제공 불가한 항목이 사용자 화면에 그대로 노출된다. 예외도 경고도 로그도 남지 않아 관리자 화면과 나란히 보지 않으면 드러나지 않는다. - 결제수단에 지정한 PG, 현금영수증 발급사, 검색엔진 드라이버 세 축에 같은 판정을 적용. 공개 응답에서는 제거하고 관리자 응답에는 표시를 남긴다 — 운영자가 고쳐야 할 대상이라 감추면 복구 경로가 사라진다. 서버 검증도 같은 판정을 공유해 화면 우회 제출을 막는다. - 설정 단건 저장이 벌크 저장과 다른 경로를 타던 문제를 코어·이커머스·게시판에서 정리. 저장은 성공하고 파생 값만 갱신되지 않아 어느 화면에서 저장했느냐로 결과가 갈렸다. - 단건 저장의 값 형태 계약 신설. 빈 값으로 되돌릴 수 있게 하고, 타입은 defaults.json 의 기본값을 SSoT 로 삼는다. 해석 불가한 입력은 조용히 캐스팅하지 않고 거절한다. - 드라이버 셀렉트를 서버 카탈로그에 바인딩하고, 저장값이 카탈로그에 없으면 그 값을 드러내는 안내를 8곳 전수에 붙인다. 종전에는 빈 칸으로만 보여 무엇이 저장됐는지 알 수 없었다. 시나리오 매니페스트 8종을 면제 없이 신설했다. `audit:allow` 는 cross product 와 effects 검사를 함께 끄므로, 면제가 걸린 매니페스트에 선언을 추가하면 검증이 회복되지 않는다. 그 아래에서 대응 테스트가 없는 effect 이름 2건이 살아남아 있었고, 함께 정리했다.
361 lines
15 KiB
PHP
361 lines
15 KiB
PHP
<?php
|
|
|
|
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Resources;
|
|
|
|
use App\Extension\HookManager;
|
|
use Illuminate\Http\Request;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIdentifierType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptIssueStatus;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptTransactionType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\CashReceiptType;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
|
|
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
|
|
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderPaymentResource;
|
|
use Modules\Sirsoft\Ecommerce\Http\Resources\OrderResource;
|
|
use Modules\Sirsoft\Ecommerce\Models\Order;
|
|
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
|
|
use Modules\Sirsoft\Ecommerce\Services\CashReceiptService;
|
|
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
|
|
use Modules\Sirsoft\Ecommerce\Tests\Concerns\RegistersTestCashReceiptProvider;
|
|
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
|
|
/**
|
|
* 현금영수증 리소스 노출 테스트 (A-5)
|
|
*
|
|
* OrderResource 는 활성 영수증 1건(cash_receipt)과 전체 이력(cash_receipts)을 노출한다.
|
|
* 전체 이력은 관리자 화면의 "취소 성공 + 재발급 실패" 경고 배지 판정 근거다.
|
|
* 어느 쪽도 식별번호 원본이나 프로바이더 원응답을 노출하지 않는다.
|
|
*/
|
|
class CashReceiptResourceTest extends ModuleTestCase
|
|
{
|
|
use RegistersTestCashReceiptProvider;
|
|
|
|
private const PROVIDER = 'tosspayments';
|
|
|
|
private const IDENTIFIER = '01012345678';
|
|
|
|
private bool $issueShouldFail = false;
|
|
|
|
private int $receiptSequence = 0;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
$this->issueShouldFail = false;
|
|
$this->receiptSequence = 0;
|
|
|
|
$this->registerCashReceiptProvider(self::PROVIDER);
|
|
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', self::PROVIDER);
|
|
}
|
|
|
|
private function registerProvider(): void
|
|
{
|
|
HookManager::addFilter(
|
|
'sirsoft-ecommerce.cash_receipt.issue',
|
|
function (array $result, Order $order, string $provider, array $payload) {
|
|
if ($provider !== self::PROVIDER) {
|
|
return $result;
|
|
}
|
|
|
|
if ($this->issueShouldFail) {
|
|
return [
|
|
'success' => false,
|
|
'error_code' => 'PROVIDER_ERROR',
|
|
'error_message' => '발급 실패',
|
|
'receipt_key' => null,
|
|
'receipt_url' => null,
|
|
'issue_number' => null,
|
|
'raw_response' => null,
|
|
];
|
|
}
|
|
|
|
$key = 'receipt-'.(++$this->receiptSequence);
|
|
|
|
return [
|
|
'success' => true,
|
|
'error_code' => null,
|
|
'error_message' => null,
|
|
'receipt_key' => $key,
|
|
'receipt_url' => "https://example.test/receipt/{$key}",
|
|
'issue_number' => '12345678',
|
|
// 원응답에 민감 정보가 섞여 와도 리소스는 노출하지 않아야 한다.
|
|
'raw_response' => ['customerIdentityNumber' => self::IDENTIFIER],
|
|
];
|
|
},
|
|
);
|
|
}
|
|
|
|
private function makeOrder(): Order
|
|
{
|
|
$order = Order::factory()->create([
|
|
'total_amount' => 11000,
|
|
'total_cash_equivalent_amount' => 11000,
|
|
'total_tax_amount' => 11000,
|
|
'total_tax_free_amount' => 0,
|
|
]);
|
|
|
|
OrderPayment::factory()->forOrder($order)->create([
|
|
'payment_method' => PaymentMethodEnum::DBANK,
|
|
'payment_status' => PaymentStatusEnum::PAID,
|
|
'receipt_url' => 'https://pg.example.test/slip/1',
|
|
]);
|
|
|
|
return $order->fresh();
|
|
}
|
|
|
|
/**
|
|
* 관계를 로드한 뒤 OrderResource 의 현금영수증 관련 필드만 직렬화합니다.
|
|
*
|
|
* 주문 리소스 전체를 json_encode 하면 미로드 관계(shippingAddress 등)의 리소스가
|
|
* null 을 만나 터진다 — 본 테스트의 관심사가 아니므로 해당 필드만 뽑아 직렬화한다.
|
|
*
|
|
* @return array{payment: array, cash_receipt: array|null, cash_receipts: array}
|
|
*/
|
|
private function resourceArray(Order $order): array
|
|
{
|
|
$order = $order->fresh();
|
|
$order->load(['payment', 'cashReceipts']);
|
|
|
|
$request = Request::create('/');
|
|
$array = (new OrderResource($order))->toArray($request);
|
|
|
|
return [
|
|
'payment' => json_decode(json_encode($array['payment']), true),
|
|
'cash_receipt' => json_decode(json_encode($array['cash_receipt']), true),
|
|
'cash_receipts' => json_decode(json_encode($array['cash_receipts']), true),
|
|
];
|
|
}
|
|
|
|
#[Test]
|
|
public function 현금영수증_발급이_결제영수증_주소를_덮어쓰지_않는다(): void
|
|
{
|
|
// payment.receipt_url = PG 매출전표, cash_receipts.receipt_url = 현금영수증.
|
|
// 한 컬럼이 두 의미를 갖지 않도록 서로 침범하지 않아야 한다.
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
|
|
$payment = $order->fresh()->payment;
|
|
$array = json_decode(json_encode(
|
|
(new OrderPaymentResource($payment))->toArray(Request::create('/'))
|
|
), true);
|
|
|
|
$this->assertSame('https://pg.example.test/slip/1', $array['receipt_url'], 'PG 매출전표가 보존되어야 한다');
|
|
$this->assertSame('https://pg.example.test/slip/1', $array['pg_receipt_url']);
|
|
$this->assertTrue($array['is_cash_receipt_issued']);
|
|
$this->assertSame('*******5678', $array['cash_receipt_identifier']);
|
|
$this->assertNotNull($array['cash_receipt_issued_at']);
|
|
|
|
// 평문·암호문은 어디에도 없어야 한다
|
|
$this->assertStringNotContainsString(self::IDENTIFIER, json_encode($array));
|
|
$this->assertArrayNotHasKey('cash_receipt_identifier_encrypted', $array);
|
|
}
|
|
|
|
#[Test]
|
|
public function 주문_리소스가_결제영수증과_현금영수증을_따로_노출한다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
|
|
$array = $this->resourceArray($order);
|
|
|
|
// 두 영수증이 각각 다른 경로로 조회된다 (복합결제 도입 시 두 버튼을 동시 노출 가능)
|
|
$this->assertSame('https://pg.example.test/slip/1', $array['payment']['pg_receipt_url']);
|
|
|
|
$this->assertNotNull($array['cash_receipt']);
|
|
$this->assertSame('https://example.test/receipt/receipt-1', $array['cash_receipt']['receipt_url']);
|
|
$this->assertSame('*******5678', $array['cash_receipt']['identifier_masked']);
|
|
$this->assertSame(CashReceiptType::INCOME->value, $array['cash_receipt']['receipt_type']);
|
|
}
|
|
|
|
#[Test]
|
|
public function 발급_이력이_없으면_활성_영수증은_null_이다(): void
|
|
{
|
|
$order = $this->makeOrder();
|
|
|
|
$array = $this->resourceArray($order);
|
|
|
|
$this->assertNull($array['cash_receipt']);
|
|
$this->assertCount(0, $array['cash_receipts']);
|
|
}
|
|
|
|
#[Test]
|
|
public function 재발급_실패_상태는_활성없음_과_실패이력으로_드러난다(): void
|
|
{
|
|
// 관리자 화면의 "경고 배지 + 수동 재발급" 판정 근거.
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
$service = app(CashReceiptService::class);
|
|
$service->issue($order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE);
|
|
|
|
HookManager::addFilter(
|
|
'sirsoft-ecommerce.cash_receipt.cancel',
|
|
fn (array $result, Order $order, string $provider, string $receiptKey) => [
|
|
'success' => true, 'error_code' => null, 'error_message' => null,
|
|
'receipt_key' => $receiptKey, 'raw_response' => null,
|
|
],
|
|
);
|
|
|
|
$this->issueShouldFail = true;
|
|
$order->fresh()->update(['total_cash_equivalent_amount' => 5500, 'total_tax_amount' => 5500]);
|
|
$service->syncFromOrder($order->fresh(), '부분환불');
|
|
|
|
$array = $this->resourceArray($order);
|
|
|
|
$this->assertNull($array['cash_receipt'], '활성 영수증이 없어야 한다');
|
|
|
|
// 최신순: 실패한 발급 → 취소 → 최초 발급
|
|
$ledger = $array['cash_receipts'];
|
|
$this->assertCount(3, $ledger);
|
|
$this->assertSame(CashReceiptIssueStatus::FAILED->value, $ledger[0]['issue_status']);
|
|
$this->assertSame('PROVIDER_ERROR', $ledger[0]['error_code']);
|
|
}
|
|
|
|
#[Test]
|
|
public function 리소스는_거래유형과_발급상태의_표시명을_함께_노출한다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
|
|
$receipt = $this->resourceArray($order)['cash_receipt'];
|
|
|
|
// 관리자 발급 이력 표는 원시값(issue / COMPLETED)이 아니라 표시명을 보여줘야 한다.
|
|
$this->assertSame(CashReceiptTransactionType::ISSUE->label(), $receipt['transaction_type_label']);
|
|
$this->assertSame(CashReceiptIssueStatus::COMPLETED->label(), $receipt['issue_status_label']);
|
|
}
|
|
|
|
#[Test]
|
|
public function 취소_이력의_상태는_발급_완료로_표시되지_않는다(): void
|
|
{
|
|
$this->registerProvider();
|
|
HookManager::addFilter(
|
|
'sirsoft-ecommerce.cash_receipt.cancel',
|
|
fn (array $result, Order $order, string $provider, string $receiptKey) => [
|
|
'success' => true, 'error_code' => null, 'error_message' => null,
|
|
'receipt_key' => $receiptKey, 'raw_response' => null,
|
|
],
|
|
);
|
|
|
|
$order = $this->makeOrder();
|
|
$service = app(CashReceiptService::class);
|
|
|
|
$service->issue($order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE);
|
|
$service->cancelAll($order->fresh(), '테스트 취소');
|
|
|
|
$ledger = $this->resourceArray($order)['cash_receipts'];
|
|
$cancelRow = collect($ledger)->firstWhere('transaction_type', CashReceiptTransactionType::CANCEL);
|
|
|
|
$this->assertNotNull($cancelRow, '취소 이력이 있어야 한다');
|
|
|
|
// issue_status 는 "그 거래가 성공했는가" 이므로 COMPLETED 다. 그러나 취소 행에 "발급 완료" 라고
|
|
// 쓰면 관리자가 오해한다 — 이력 표에는 거래유형과 무관한 중립 표현을 쓴다.
|
|
$this->assertSame(CashReceiptIssueStatus::COMPLETED->value, $cancelRow['issue_status']);
|
|
$this->assertStringNotContainsString('발급', $cancelRow['result_label']);
|
|
$this->assertNotEmpty($cancelRow['result_label']);
|
|
}
|
|
|
|
#[Test]
|
|
public function 발급에_실패한_이력도_표시용_일시를_가진다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$this->issueShouldFail = true;
|
|
|
|
$order = $this->makeOrder();
|
|
|
|
try {
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
} catch (\Throwable) {
|
|
// 발급 실패는 이력만 남기고 삼켜지거나 예외로 전파된다 — 어느 쪽이든 이력은 남는다.
|
|
}
|
|
|
|
$failed = $this->resourceArray($order)['cash_receipts'][0];
|
|
|
|
// 실패 이력은 issued_at 이 null 이다. 그대로 두면 관리자 이력 표의 일시가 '-' 로 비어 버린다.
|
|
$this->assertSame(CashReceiptIssueStatus::FAILED->value, $failed['issue_status']);
|
|
$this->assertNull($failed['issued_at']);
|
|
$this->assertNotEmpty($failed['occurred_at_formatted'], '실패 이력도 발생 시각을 표시할 수 있어야 한다');
|
|
}
|
|
|
|
/**
|
|
* 발급사 플러그인이 제거되면 발급사 필드가 null 로 내려간다. (비회귀 pin)
|
|
*
|
|
* 화면은 이 필드로 신규 발급 버튼을 켠다. 죽은 발급사 ID 를 그대로 내려보내면
|
|
* 버튼이 살아 있고, 눌러도 실패 이력만 쌓인다.
|
|
*
|
|
* @scenario provider_state=dead
|
|
*
|
|
* @effects dead_provider_nulled_in_resource
|
|
*/
|
|
#[Test]
|
|
public function 발급사가_제거되면_발급사_필드가_null_이_된다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
|
|
// 살아 있는 동안은 그대로 내려간다 (존재를 먼저 확정)
|
|
$this->assertSame(self::PROVIDER, $this->resourceArray($order)['payment']['cash_receipt_provider'] ?? null);
|
|
|
|
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
|
|
|
|
$payment = $this->resourceArray($order->fresh())['payment'];
|
|
|
|
$this->assertArrayHasKey('cash_receipt_provider', $payment);
|
|
$this->assertNull($payment['cash_receipt_provider'], '레지스트리에 없는 발급사가 그대로 노출되었습니다.');
|
|
}
|
|
|
|
/**
|
|
* 발급사가 제거돼도 이미 발급한 이력은 계속 노출된다. (비회귀 pin)
|
|
*
|
|
* 발급 이력과 영수증 링크는 구매자·운영자의 증빙이다. 발급사 유무로 감추면
|
|
* 이미 발급된 영수증의 확인 경로가 사라진다.
|
|
*
|
|
* @scenario provider_state=dead
|
|
*
|
|
* @effects existing_receipt_history_survives_dead_provider
|
|
*/
|
|
#[Test]
|
|
public function 발급사가_제거돼도_기존_발급_이력은_계속_노출된다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
|
|
app(EcommerceSettingsService::class)->setSetting('order_settings.cash_receipt_provider', 'ghost_provider');
|
|
|
|
$array = $this->resourceArray($order->fresh());
|
|
|
|
$this->assertNotEmpty($array['cash_receipts'], '발급사 제거로 발급 이력이 사라졌습니다.');
|
|
$this->assertNotNull($array['cash_receipt'], '활성 영수증이 사라졌습니다.');
|
|
$this->assertSame(self::PROVIDER, $array['cash_receipts'][0]['provider'], '이력의 발급사 스냅샷이 바뀌었습니다.');
|
|
}
|
|
|
|
#[Test]
|
|
public function 리소스는_프로바이더_원응답을_노출하지_않는다(): void
|
|
{
|
|
$this->registerProvider();
|
|
$order = $this->makeOrder();
|
|
app(CashReceiptService::class)->issue(
|
|
$order, CashReceiptType::INCOME, self::IDENTIFIER, CashReceiptIdentifierType::PHONE,
|
|
);
|
|
|
|
$array = $this->resourceArray($order);
|
|
|
|
$this->assertArrayNotHasKey('raw_response', $array['cash_receipt']);
|
|
$this->assertStringNotContainsString(self::IDENTIFIER, json_encode($array['cash_receipt']));
|
|
}
|
|
}
|