Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/OrderProcessingServiceTest.php
T
HeuJung 494e581973 fix(security): KVE-2026 신고 4건 조치 — 관리자 API 권한 게이트 + 금전 처리 경쟁 조건
KISA 신고 4건(KVE-2026-1886/1887/1893/1894)을 검증하고, 미수정 2건과
동종 결함 전수 조사에서 나온 결함을 함께 조치했다.

권한 게이트 (KVE-2026-1893)

- NHN KCP 관리자 주문 연동 7경로가 admin 보유 여부만 보고 세부 권한을 보지 않아,
 업무 권한 없는 관리자가 주문번호·결제정보·수령인 연락처를 조회하고 에스크로
 배송등록까지 할 수 있었다. 조회는 orders.read, 등록은 orders.update, 설정성
 경로는 settings.read 로 게이트해 다른 PG 연동과 강도를 맞췄다.
- 마케팅 채널 저장은 코어 플러그인 설정과 같은 저장소를 덮어쓰는 우회 경로라
 core.plugins.update 를 부착했다.

첨부 해시 노출 (KVE-2026-1894 잔재)

- 비밀글의 썸네일 URL 이 목록·상세 응답에 그대로 실려 첨부 해시가 노출됐다.
 이미지 서빙은 이미 차단돼 있었으나 식별값 자체가 나갔다. 첨부 목록과 같은
 게이트를 써서 값만 가리고 필드는 유지한다.

경쟁 조건 (KVE-2026-1886 + 전수 조사)

- 쿠폰 차감을 조회 후 갱신에서 조건부 UPDATE 로 바꿔 1회 제한 쿠폰의 동시
 사용을 막고, 선점당한 주문은 409 로 되돌린다.
- 라이브 병렬 재현에서 그 롤백이 동작하지 않는 것을 확인했다. Action 훅 기본값이
 큐 래핑 + afterCommit 이라 금전 처리가 호출자 커밋 뒤에 실행되고 있었다.
 쿠폰·적립금 차감/복원 5개 구독에 sync 를 선언했고, 회귀 테스트는 손 등록이
 아니라 실제 등록 경로를 태워 고정했다.
- 동시 부분취소의 취소 누적 컬럼 lost update, 적립 lot 의 read-modify-write,
 주문옵션당 적립 lot 중복 생성을 각각 행 잠금·컬럼 연산·유니크 제약으로 막았다.
 기설치본의 중복 lot 은 인덱스 생성 전에 금액을 합산해 한 줄로 통합한다.

규정·문서

- sync 판정 기준을 hooks.md 에 명문화하고 /AGENTS.md Listener 표와
 coverage manifest 에 반영했다. 같은 증상을 다시 만났을 때의 진단 경로를
 트러블슈팅 사례로 남겼다.
2026-08-22 01:19:36 +09:00

3169 lines
133 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Extension\HookManager;
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\App;
use Modules\Sirsoft\Ecommerce\Database\Factories\TempOrderFactory;
use Modules\Sirsoft\Ecommerce\DTO\AppliedPromotions;
use Modules\Sirsoft\Ecommerce\DTO\AppliedShippingPolicy;
use Modules\Sirsoft\Ecommerce\DTO\CouponApplication;
use Modules\Sirsoft\Ecommerce\DTO\ItemCalculation;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\PromotionsSummary;
use Modules\Sirsoft\Ecommerce\DTO\Summary;
use Modules\Sirsoft\Ecommerce\Enums\CouponDiscountType;
use Modules\Sirsoft\Ecommerce\Enums\CouponIssueRecordStatus;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetScope;
use Modules\Sirsoft\Ecommerce\Enums\CouponTargetType;
use Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentMethodEnum;
use Modules\Sirsoft\Ecommerce\Enums\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
use Modules\Sirsoft\Ecommerce\Exceptions\CouponAlreadyUsedException;
use Modules\Sirsoft\Ecommerce\Exceptions\OrderAmountChangedException;
use Modules\Sirsoft\Ecommerce\Exceptions\PaymentAmountMismatchException;
use Modules\Sirsoft\Ecommerce\Exceptions\UnsupportedPaymentCurrencyException;
use Modules\Sirsoft\Ecommerce\Listeners\CouponUseListener;
use Modules\Sirsoft\Ecommerce\Models\Cart;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\MileageTransaction;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\TempOrder;
use Modules\Sirsoft\Ecommerce\Models\UserAddress;
use Modules\Sirsoft\Ecommerce\Services\EcommerceSettingsService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Services\OrderProcessingService;
use Modules\Sirsoft\Ecommerce\Services\StockService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
/**
* 주문 생성 서비스 테스트
*/
class OrderProcessingServiceTest extends ModuleTestCase
{
protected OrderProcessingService $service;
protected function setUp(): void
{
parent::setUp();
$this->cleanupMileageSettingsFile();
$this->service = app(OrderProcessingService::class);
}
protected function tearDown(): void
{
$this->cleanupMileageSettingsFile();
parent::tearDown();
}
/**
* 테스트 간 격리를 위해 testing 격리 경로의 마일리지 설정 파일을 제거합니다.
* (setSetting 으로 deduction_timing 등을 변경한 테스트가 다음 테스트에 누설되지 않도록)
*/
private function cleanupMileageSettingsFile(): void
{
foreach (['mileage.json', 'order_settings.json'] as $name) {
$file = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/'.$name);
if (file_exists($file)) {
unlink($file);
}
}
}
/**
* 마일리지 사용 정책을 무제한으로 완화합니다.
*
* 주문 확정 시점에 사용 정책(min_use_amount / use_unit / max_use_*)이 강제되므로(#493 E1),
* 마일리지가 주제가 아닌 테스트는 배포 기본값(최소 1,000 / 상한 50,000)에 걸린다.
* 이 테스트들의 관심사는 주문 메타·결제 상태이므로 정책을 열어 두고 본래 대상만 검증한다.
* 정책 강제 자체는 MileageUsageLimitTest 가 다룬다.
*/
private function allowAnyMileageUsage(): void
{
$dir = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($dir.'/mileage.json', json_encode([
'enabled' => true,
'default_earn_rate' => 0,
'earn_trigger' => 'confirmed',
'earn_delay_days' => 0,
'currency_rules' => [[
'currency_code' => 'KRW',
'point_value' => 1,
'min_use_amount' => 0,
'use_unit' => 1,
'max_use_type' => 'percent',
'max_use_percent' => 100,
'max_use_value' => 0,
]],
'expiry_enabled' => false,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
}
/**
* 카드(card) 결제수단의 마일리지 차감 시점을 설정합니다. (마일리지/MP06)
*
* 차감 시점은 결제수단별(order_settings.payment_methods.*.mileage_deduction_timing)로 관리되므로,
* 단일 키 대신 카드 결제수단 설정을 저장한다.
*
* @param string $timing order_placed | payment_complete
*/
private function setCardMileageTiming(string $timing): void
{
app(EcommerceSettingsService::class)->saveSettings([
'order_settings' => [
'payment_methods' => [
['id' => 'card', 'sort_order' => 1, 'is_active' => true, 'min_order_amount' => 0, 'mileage_deduction_timing' => $timing],
],
],
]);
app(EcommerceSettingsService::class)->clearCache();
}
// ===== 기존 테스트 (determineInitialStatus, isFirstOrder, generateOrderNumber) =====
public function test_determine_initial_status_returns_pending_payment_for_vbank(): void
{
// Protected 메서드 테스트를 위한 리플렉션
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('determineInitialStatus');
$method->setAccessible(true);
// 결제할 금액이 있는 일반 주문(finalAmount > 0)
$status = $method->invoke($this->service, 'vbank', 50000);
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $status);
}
public function test_determine_initial_status_returns_pending_order_for_card(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('determineInitialStatus');
$method->setAccessible(true);
$status = $method->invoke($this->service, 'card', 50000);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $status);
}
public function test_determine_initial_status_returns_pending_order_for_pg(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('determineInitialStatus');
$method->setAccessible(true);
$status = $method->invoke($this->service, 'pg', 50000);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $status);
}
public function test_is_first_order_returns_true_for_new_user(): void
{
$user = User::factory()->create();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('isFirstOrder');
$method->setAccessible(true);
$isFirstOrder = $method->invoke($this->service, $user->id);
$this->assertTrue($isFirstOrder);
}
public function test_is_first_order_returns_false_for_user_with_orders(): void
{
$user = User::factory()->create();
// 기존 주문 생성
Order::factory()->create(['user_id' => $user->id]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('isFirstOrder');
$method->setAccessible(true);
$isFirstOrder = $method->invoke($this->service, $user->id);
$this->assertFalse($isFirstOrder);
}
public function test_is_first_order_returns_false_for_guest(): void
{
// 비회원 주문(user_id = null)은 회원 식별이 불가능해 첫 주문 혜택 대상이 아니다.
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('isFirstOrder');
$method->setAccessible(true);
$isFirstOrder = $method->invoke($this->service, null);
$this->assertFalse($isFirstOrder);
}
public function test_generate_order_number_returns_unique_string(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('generateOrderNumber');
$method->setAccessible(true);
$orderNumber1 = $method->invoke($this->service);
$orderNumber2 = $method->invoke($this->service);
$this->assertIsString($orderNumber1);
$this->assertIsString($orderNumber2);
$this->assertNotEquals($orderNumber1, $orderNumber2);
}
public function test_generate_order_number_has_expected_format(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('generateOrderNumber');
$method->setAccessible(true);
$orderNumber = $method->invoke($this->service);
// SequenceService TIMESTAMP 알고리즘: Ymd-His + 밀리초3자리 + 랜덤1자리 (예: 20260208-1435226549)
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $orderNumber);
}
public function test_generate_order_number_is_recorded_in_sequence_codes(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('generateOrderNumber');
$method->setAccessible(true);
$orderNumber = $method->invoke($this->service);
// 채번된 주문번호가 ecommerce_sequence_codes 테이블에 기록되는지 확인
$this->assertDatabaseHas('ecommerce_sequence_codes', [
'type' => 'order',
'code' => $orderNumber,
]);
}
public function test_generate_order_number_produces_unique_values(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('generateOrderNumber');
$method->setAccessible(true);
$orderNumbers = [];
for ($i = 0; $i < 10; $i++) {
$orderNumbers[] = $method->invoke($this->service);
}
// 모든 주문번호가 유일해야 함
$uniqueNumbers = array_unique($orderNumbers);
$this->assertCount(10, $uniqueNumbers);
// 모든 주문번호가 타임스탬프 형식이어야 함
foreach ($orderNumbers as $orderNumber) {
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $orderNumber);
}
}
// ===== createFromTempOrder 재계산 통합 테스트 =====
/**
* OrderCalculationService를 mock하여 재계산 결과를 주입하고, 서비스를 재생성합니다.
*
* @param OrderCalculationResult $result 재계산 결과
*/
protected function mockCalculationService(OrderCalculationResult $result): void
{
$mock = $this->createMock(OrderCalculationService::class);
$mock->method('calculate')->willReturn($result);
$this->app->instance(OrderCalculationService::class, $mock);
$this->service = app(OrderProcessingService::class);
}
/**
* 기본 계산 결과 DTO를 생성합니다.
*
* @param int $finalAmount 최종 금액
* @param array $overrides 오버라이드 옵션
*/
protected function makeCalculationResult(int $finalAmount = 103000, array $overrides = []): OrderCalculationResult
{
$summary = new Summary(
subtotal: $overrides['subtotal'] ?? 100000,
totalDiscount: $overrides['total_discount'] ?? 0,
productCouponDiscount: $overrides['product_coupon_discount'] ?? 0,
codeDiscount: $overrides['code_discount'] ?? 0,
totalShipping: $overrides['total_shipping'] ?? 3000,
taxableAmount: $overrides['taxable_amount'] ?? 93636,
taxFreeAmount: $overrides['tax_free_amount'] ?? 0,
pointsUsed: $overrides['points_used'] ?? 0,
pointsEarning: $overrides['points_earning'] ?? 0,
paymentAmount: $overrides['payment_amount'] ?? $finalAmount,
finalAmount: $finalAmount,
);
$items = $overrides['items'] ?? [];
$promotions = $overrides['promotions'] ?? new PromotionsSummary;
return new OrderCalculationResult(
items: $items,
summary: $summary,
promotions: $promotions,
validationErrors: $overrides['validation_errors'] ?? [],
);
}
/**
* 테스트용 기본 TempOrder를 생성합니다.
*
* @param User $user 사용자
* @param int $finalAmount 최종 금액 (calculation_result.summary.final_amount)
* @return TempOrder
*/
protected function createTestTempOrder(User $user, int $finalAmount = 103000)
{
return TempOrderFactory::new()
->forUser($user)
->withCalculationResult([
'summary' => [
'subtotal' => 100000,
'total_discount' => 0,
'product_coupon_discount' => 0,
'code_discount' => 0,
'total_shipping' => 3000,
'final_amount' => $finalAmount,
'taxable_amount' => 93636,
'tax_free_amount' => 0,
'points_used' => 0,
],
'items' => [],
'shippings' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
}
public function test_create_from_temp_order_creates_order(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// mock: 재계산 결과가 저장된 금액과 동일
$this->mockCalculationService($this->makeCalculationResult(103000));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
'card',
103000,
'문앞에 놓아주세요'
);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals($user->id, $order->user_id);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
$this->assertEquals(103000, $order->total_amount);
$this->assertMatchesRegularExpression('/^\d{8}-\d{10}$/', $order->order_number);
}
/**
* 주문 생성 시 현재 화면 언어가 배송지 orderer_locale 에 저장된다.
*
* 비회원 알림(이메일)이 주문 화면 언어로 발송되도록 하는 근거 데이터.
*/
public function test_create_from_temp_order_stores_current_locale_on_address(): void
{
App::setLocale('en');
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
$this->mockCalculationService($this->makeCalculationResult(103000));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
'card',
103000,
null
);
$this->assertSame('en', $order->fresh()->shippingAddress?->orderer_locale);
}
/**
* 주문 생성 시 결제 레코드에 결제요청일시·구매자 정보·부가세·결제명·디바이스가
* 모두 채워지는지 검증합니다 (PG 콜백을 거치지 않는 수단에서도 NULL 로 남지 않아야 함).
*
* 회귀 배경: createOrderPayment 가 결제요청일시·구매자정보·결제명·부가세·디바이스 컬럼을
* 설정하지 않아, 무통장 등 PG 콜백 없는 주문에서 영구 NULL 로 남던 결함.
*/
public function test_create_from_temp_order_populates_payment_record_fields(): void
{
$user = User::factory()->create();
// 실제 상품/옵션 생성 (FK 제약조건 충족 + payment_name 산출용 상품명)
$product = Product::factory()->create();
$productOption = ProductOption::factory()->create(['product_id' => $product->id]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
['cart_id' => 1, 'product_id' => $product->id, 'product_option_id' => $productOption->id, 'quantity' => 1],
])
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$item = new ItemCalculation(
productId: $product->id,
productOptionId: $productOption->id,
quantity: 1,
unitPrice: 100000,
subtotal: 100000,
finalAmount: 100000,
productName: '테스트 상품 A',
);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'taxable_amount' => 93636,
'items' => [$item],
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'buyer@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
'dbank',
103000,
null,
'홍길동',
['bank_code' => '004', 'bank_name' => '국민은행', 'account_number' => '123-456', 'account_holder' => '판매자']
);
$payment = $order->payment;
$this->assertNotNull($payment, '결제 레코드가 생성되어야 함');
// 결제요청일시 — 전 결제수단 공통, 생성 시점에 기록
$this->assertNotNull($payment->payment_started_at, 'payment_started_at 은 생성 시점에 기록되어야 함');
// 구매자 정보 — 주문자 정보가 결제 레코드에 기록
$this->assertEquals('홍길동', $payment->buyer_name);
$this->assertEquals('buyer@example.com', $payment->buyer_email);
$this->assertEquals('010-1234-5678', $payment->buyer_phone);
// 부가세 — 과세표준(93636)의 1/11
$this->assertEquals((int) round(93636 / 11), (int) $payment->vat_amount);
// 결제명 — 상품명 (단일 항목이므로 상품명 그대로)
$this->assertEquals('테스트 상품 A', $payment->payment_name);
// 결제 디바이스 — User-Agent 기반 (테스트 환경은 비어 'pc')
$this->assertContains($payment->payment_device, ['pc', 'mobile']);
}
public function test_create_from_temp_order_with_vbank_sets_pending_payment(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user, 50000);
$this->mockCalculationService($this->makeCalculationResult(50000, [
'subtotal' => 50000,
'total_shipping' => 0,
'taxable_amount' => 45454,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '김철수', 'phone' => '010-9876-5432', 'email' => 'kim@example.com'],
['recipient_name' => '김철수', 'recipient_phone' => '010-9876-5432', 'zipcode' => '54321', 'address' => '부산시 해운대구', 'address_detail' => '마린시티 1동'],
'vbank',
50000,
null,
'김철수'
);
$this->assertInstanceOf(Order::class, $order);
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $order->order_status);
}
public function test_create_from_temp_order_preserves_temp_order(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user, 30000);
$this->mockCalculationService($this->makeCalculationResult(30000, [
'subtotal' => 30000,
'total_shipping' => 0,
'taxable_amount' => 27273,
]));
$tempOrderId = $tempOrder->id;
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test Address', 'address_detail' => 'Test Detail'],
'card',
30000
);
// PG 결제 취소 → 재결제를 위해 임시주문 유지
$this->assertDatabaseHas('ecommerce_temp_orders', ['id' => $tempOrderId]);
}
// ===== 신규 테스트: 프로모션/배송비/마일리지 데이터 누락 수정 검증 =====
public function test_create_from_temp_order_populates_promotions_applied_snapshot(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// 쿠폰이 적용된 프로모션 결과 구성
$coupon = new CouponApplication(
couponId: 1,
couponIssueId: 101,
name: '10% 할인 쿠폰',
targetType: 'product_amount',
discountType: 'rate',
discountValue: 10,
totalDiscount: 10000,
);
$productPromotions = new AppliedPromotions(coupons: [$coupon]);
$promotions = new PromotionsSummary(productPromotions: $productPromotions);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'promotions' => $promotions,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
// promotions_applied_snapshot이 비어있지 않아야 함
$snapshot = $order->promotions_applied_snapshot;
$this->assertNotEmpty($snapshot);
$this->assertArrayHasKey('product_promotions', $snapshot);
$this->assertNotEmpty($snapshot['product_promotions']['coupons']);
$this->assertEquals(101, $snapshot['product_promotions']['coupons'][0]['coupon_issue_id']);
}
public function test_create_from_temp_order_populates_shipping_policy_snapshot(): void
{
$user = User::factory()->create();
// 실제 상품/옵션 생성 (FK 제약조건 충족)
$product = Product::factory()->create();
$productOption = ProductOption::factory()->create([
'product_id' => $product->id,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
[
'cart_id' => 1,
'product_id' => $product->id,
'product_option_id' => $productOption->id,
'quantity' => 1,
],
])
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
// 배송정책이 적용된 아이템 구성
$shippingPolicy = new AppliedShippingPolicy(
policyId: 1,
policyName: '기본 배송',
chargePolicy: 'paid',
shippingAmount: 3000,
extraShippingAmount: 0,
totalShippingAmount: 3000,
policySnapshot: ['id' => 1, 'name' => '기본 배송', 'base_fee' => 3000],
);
$item = new ItemCalculation(
productId: $product->id,
productOptionId: $productOption->id,
quantity: 1,
unitPrice: 100000,
subtotal: 100000,
finalAmount: 100000,
appliedShippingPolicy: $shippingPolicy,
);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'items' => [$item],
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
// shipping_policy_applied_snapshot 은 `{items: [...], address: {...}}` 구조여야 한다.
// 항목 목록과 배송지 메타를 한 배열에 섞으면 PHP 배열이 non-sequential 이 되어
// json_encode 가 객체로 직렬화하고, 배열을 전제하는 화면에서 표시가 사라진다.
$snapshot = $order->shipping_policy_applied_snapshot;
$this->assertNotEmpty($snapshot);
$this->assertArrayHasKey('items', $snapshot);
$this->assertArrayHasKey('address', $snapshot);
$this->assertEquals($productOption->id, $snapshot['items'][0]['product_option_id']);
$this->assertEquals(1, $snapshot['items'][0]['policy']['policy_id']);
// items 는 JSON 에서 반드시 배열 리터럴로 나가야 한다 (객체면 프론트 .find 가 죽는다)
$this->assertStringContainsString('"items":[', json_encode($snapshot));
}
/**
* 주문의 총 무게/부피는 주문 옵션 소계의 합으로 기록된다 (공개 #94 / N7).
*
* 기존에는 0 으로 고정 기록되어, 배송사 연동·운임 정산이 이 값을 읽으면
* 무게 없는 주문으로 취급됐다. 값의 단위는 상품 옵션과 같은 g / cm³ 다.
*/
public function test_create_from_temp_order_records_total_weight_and_volume(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$productOption = ProductOption::factory()->create([
'product_id' => $product->id,
'weight' => 500,
'volume' => 1200,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
[
'cart_id' => 1,
'product_id' => $product->id,
'product_option_id' => $productOption->id,
'quantity' => 3,
],
])
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$item = new ItemCalculation(
productId: $product->id,
productOptionId: $productOption->id,
quantity: 3,
unitPrice: 100000,
subtotal: 300000,
finalAmount: 300000,
);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'items' => [$item],
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
$order->refresh();
// 단위 무게는 상품 옵션의 g 값을 그대로 복사한다 (kg 환산은 배송비 계산 시점에만)
$orderOption = $order->options()->first();
$this->assertEquals(500.0, (float) $orderOption->unit_weight);
$this->assertEquals(1200.0, (float) $orderOption->unit_volume);
$this->assertEquals(1500.0, (float) $orderOption->subtotal_weight);
$this->assertEquals(3600.0, (float) $orderOption->subtotal_volume);
// 주문 합계는 옵션 소계의 합
$this->assertEquals(1500.0, (float) $order->total_weight);
$this->assertEquals(3600.0, (float) $order->total_volume);
}
public function test_create_from_temp_order_saves_order_meta_with_calculation_input(): void
{
$this->allowAnyMileageUsage();
$user = User::factory()->create();
// 마일리지 사용 훅이 실제 FIFO 차감을 수행하므로 사용액(500) 이상 잔액 시드
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => 2000,
'remaining_amount' => 2000,
'balance_after' => 2000,
]);
$calculationInput = [
'promotions' => [
'item_coupons' => [1 => [101]],
'order_coupon_issue_id' => null,
'shipping_coupon_issue_id' => null,
],
'use_points' => 500,
'shipping_address' => null,
];
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withCalculationInput($calculationInput)
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$this->mockCalculationService($this->makeCalculationResult(103000));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
// order_meta에 calculation_input이 포함되어야 함
$meta = $order->order_meta;
$this->assertArrayHasKey('temp_order_id', $meta);
$this->assertArrayHasKey('calculation_input', $meta);
$this->assertEquals(500, $meta['calculation_input']['use_points']);
}
public function test_create_order_shippings_uses_correct_dto_properties(): void
{
$user = User::factory()->create();
// 실제 상품/옵션 생성
$product = Product::factory()->create();
$productOption = ProductOption::factory()->create([
'product_id' => $product->id,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
[
'cart_id' => 1,
'product_id' => $product->id,
'product_option_id' => $productOption->id,
'quantity' => 2,
],
])
->withCalculationResult([
'summary' => ['final_amount' => 55000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
// 배송정책이 설정된 아이템
$shippingPolicy = new AppliedShippingPolicy(
policyId: 5,
policyName: '도서산간 배송',
chargePolicy: 'paid',
shippingAmount: 3000,
extraShippingAmount: 2000,
totalShippingAmount: 5000,
shippingDiscountAmount: 1000,
policySnapshot: ['id' => 5, 'name' => '도서산간 배송'],
);
$item = new ItemCalculation(
productId: $product->id,
productOptionId: $productOption->id,
quantity: 2,
unitPrice: 25000,
subtotal: 50000,
finalAmount: 50000,
appliedShippingPolicy: $shippingPolicy,
);
$this->mockCalculationService($this->makeCalculationResult(55000, [
'subtotal' => 50000,
'total_shipping' => 5000,
'items' => [$item],
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
55000
);
// 배송 정보가 올바른 DTO 속성값으로 저장되었는지 확인
$shipping = $order->shippings()->first();
$this->assertNotNull($shipping);
$this->assertEquals(3000, $shipping->base_shipping_amount); // shippingAmount (기본 배송비)
$this->assertEquals(2000, $shipping->extra_shipping_amount); // extraShippingAmount (추가 배송비)
$this->assertEquals(5000, $shipping->total_shipping_amount); // totalShippingAmount (총 배송비)
$this->assertEquals(1000, $shipping->shipping_discount_amount); // shippingDiscountAmount (배송비 할인)
$this->assertTrue((bool) $shipping->is_remote_area); // extraShippingAmount > 0
}
public function test_create_order_shippings_saves_delivery_policy_snapshot(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$productOption = ProductOption::factory()->create([
'product_id' => $product->id,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
[
'cart_id' => 1,
'product_id' => $product->id,
'product_option_id' => $productOption->id,
'quantity' => 1,
],
])
->withCalculationResult([
'summary' => ['final_amount' => 33000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$policySnapshotData = [
'id' => 3,
'name' => '기본 배송정책',
'base_fee' => 3000,
'free_shipping_threshold' => 50000,
];
$shippingPolicy = new AppliedShippingPolicy(
policyId: 3,
policyName: '기본 배송정책',
chargePolicy: 'paid',
shippingAmount: 3000,
totalShippingAmount: 3000,
policySnapshot: $policySnapshotData,
);
$item = new ItemCalculation(
productId: $product->id,
productOptionId: $productOption->id,
quantity: 1,
unitPrice: 30000,
subtotal: 30000,
finalAmount: 30000,
appliedShippingPolicy: $shippingPolicy,
);
$this->mockCalculationService($this->makeCalculationResult(33000, [
'subtotal' => 30000,
'total_shipping' => 3000,
'items' => [$item],
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
33000
);
// delivery_policy_snapshot이 저장되었는지 확인
$shipping = $order->shippings()->first();
$this->assertNotNull($shipping);
$this->assertNotNull($shipping->delivery_policy_snapshot);
$this->assertEquals(3, $shipping->delivery_policy_snapshot['id']);
$this->assertEquals('기본 배송정책', $shipping->delivery_policy_snapshot['name']);
}
public function test_create_from_temp_order_calls_coupon_use_hook(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// 쿠폰이 적용된 계산 결과
$coupon = new CouponApplication(
couponId: 1,
couponIssueId: 201,
name: '테스트 쿠폰',
targetType: 'product_amount',
discountType: 'fixed',
discountValue: 5000,
totalDiscount: 5000,
);
$productPromotions = new AppliedPromotions(coupons: [$coupon]);
$promotions = new PromotionsSummary(productPromotions: $productPromotions);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'promotions' => $promotions,
]));
// 훅 호출 감지
$hookCalled = false;
$capturedCouponIds = [];
HookManager::addAction('sirsoft-ecommerce.coupon.use', function ($couponIds, $order) use (&$hookCalled, &$capturedCouponIds) {
$hookCalled = true;
$capturedCouponIds = $couponIds;
});
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
$this->assertTrue($hookCalled, '쿠폰 사용 훅이 호출되어야 합니다');
$this->assertContains(201, $capturedCouponIds);
}
/**
* 이미 다른 주문이 선점한 쿠폰으로 주문을 확정하면 트랜잭션 전체가 롤백되어야 합니다.
*
* 쿠폰 차감 실패를 삼키고 주문만 생성하면, 1회 제한 쿠폰의 할인이 두 주문에 모두
* 적용된 채로 확정된다 (KVE-2026-1886).
*
* @return void
*/
public function test_create_from_temp_order_rolls_back_when_coupon_already_taken(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// 선행 주문이 이미 사용한 쿠폰 발급 레코드
$winningOrder = Order::create([
'user_id' => $user->id,
'order_number' => 'ORD-WINNER-'.uniqid(),
'order_status' => OrderStatusEnum::PENDING_PAYMENT,
'currency' => 'KRW',
'item_count' => 1,
'ordered_at' => now(),
'subtotal_amount' => 50000,
'total_amount' => 45000,
'total_paid_amount' => 45000,
]);
$couponModel = Coupon::create([
'name' => ['ko' => '선점 쿠폰', 'en' => 'Taken Coupon'],
'target_type' => CouponTargetType::PRODUCT_AMOUNT,
'discount_type' => CouponDiscountType::FIXED,
'discount_value' => 5000,
'min_order_amount' => 0,
'target_scope' => CouponTargetScope::ALL,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDays(30),
]);
$issue = CouponIssue::create([
'coupon_id' => $couponModel->id,
'user_id' => $user->id,
'coupon_code' => 'TAKEN'.uniqid(),
'status' => CouponIssueRecordStatus::USED,
'issued_at' => now(),
'expired_at' => now()->addDays(30),
'used_at' => now(),
'order_id' => $winningOrder->id,
]);
$coupon = new CouponApplication(
couponId: $couponModel->id,
couponIssueId: $issue->id,
name: '선점 쿠폰',
targetType: 'product_amount',
discountType: 'fixed',
discountValue: 5000,
totalDiscount: 5000,
);
$promotions = new PromotionsSummary(
productPromotions: new AppliedPromotions(coupons: [$coupon])
);
$this->mockCalculationService($this->makeCalculationResult(103000, [
'promotions' => $promotions,
]));
// 실제 리스너를 훅에 연결 (프로덕션과 동일 경로)
$listener = app(CouponUseListener::class);
HookManager::addAction(
'sirsoft-ecommerce.coupon.use',
fn ($couponIds, $order) => $listener->markCouponsUsed($couponIds, $order)
);
$ordersBefore = Order::query()->count();
try {
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
$this->fail('선점된 쿠폰으로 주문이 확정되어서는 안 됩니다.');
} catch (CouponAlreadyUsedException $e) {
$this->assertSame($issue->id, $e->getCouponIssueId());
}
// 주문 트랜잭션 전체 롤백 — 주문 행이 늘지 않아야 한다
$this->assertSame($ordersBefore, Order::query()->count(), '경쟁에서 밀린 주문은 생성되지 않아야 합니다.');
// 쿠폰 소유는 선행 주문 그대로
$issue->refresh();
$this->assertEquals(CouponIssueRecordStatus::USED, $issue->status);
$this->assertEquals($winningOrder->id, $issue->order_id);
}
public function test_create_from_temp_order_calls_mileage_use_hook(): void
{
$user = User::factory()->create();
// 카드 결제수단의 마일리지 차감 시점을 order_placed 로 설정 — 주문 생성 시 mileage.use 훅 발화
// (기본 payment_complete 타이밍은 결제완료 시점에 발화하며, 별도 테스트에서 검증)
$this->setCardMileageTiming('order_placed');
// 마일리지 사용 훅이 실제 FIFO 차감을 수행하므로 사용액(1000) 이상 잔액 시드
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => 2000,
'remaining_amount' => 2000,
'balance_after' => 2000,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withUsePoints(1000)
->withCalculationResult([
'summary' => ['final_amount' => 102000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$this->mockCalculationService($this->makeCalculationResult(102000, [
'points_used' => 1000,
]));
// 훅 호출 감지
$hookCalled = false;
$capturedPoints = 0;
HookManager::addAction('sirsoft-ecommerce.mileage.use', function ($points, $order) use (&$hookCalled, &$capturedPoints) {
$hookCalled = true;
$capturedPoints = $points;
});
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
102000
);
$this->assertTrue($hookCalled, '마일리지 사용 훅이 호출되어야 합니다');
$this->assertEquals(1000, $capturedPoints);
}
/**
* 마일리지 사용 시 결제예정금액(total_due_amount)은 마일리지 차감 후 금액(finalAmount)이어야 한다.
*
* 회귀: 주문 생성 시 total_due_amount 에 차감 전 paymentAmount 가 저장되어
* PG(KG 이니시스 등)·무통장 입금액이 마일리지 차감 전 금액으로 잡히던 결함.
* 결제예정금액(total_due_amount)·주문 결제금액(total_amount)은 모두 차감 후 실결제액(finalAmount)이며,
* 차감 전 paymentAmount 는 어느 컬럼에도 PG 결제 기준값으로 저장되지 않는다.
*/
public function test_create_from_temp_order_sets_total_due_amount_to_post_mileage_final_amount(): void
{
$user = User::factory()->create();
// 마일리지 사용 훅의 FIFO 차감을 위한 잔액 시드 (2,000 사용)
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => 2000,
'remaining_amount' => 2000,
'balance_after' => 2000,
]);
// 차감 전 결제금액 55,000 / 마일리지 2,000 사용 → 차감 후 53,000
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withUsePoints(2000)
->withCalculationResult([
'summary' => ['final_amount' => 53000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000,
'total_shipping' => 0,
'taxable_amount' => 50000,
'points_used' => 2000,
'payment_amount' => 55000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
53000
);
$this->assertEquals(53000, (int) $order->total_amount, '주문 결제금액(total_amount)은 마일리지 차감 후 실결제액이어야 합니다.');
$this->assertEquals(2000, (int) $order->total_points_used_amount, '사용 마일리지가 기록되어야 합니다.');
$this->assertEquals(
53000,
(int) $order->total_due_amount,
'결제예정금액(total_due_amount)은 마일리지 차감 후 실결제액이어야 합니다 — PG/무통장 입금액의 SSoT.'
);
}
public function test_create_from_temp_order_recalculates_from_stored_input(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// OrderCalculationService::calculate()가 호출되는지 확인
$mock = $this->createMock(OrderCalculationService::class);
$mock->expects($this->once()) // 정확히 1회 호출
->method('calculate')
->willReturn($this->makeCalculationResult(103000));
$this->app->instance(OrderCalculationService::class, $mock);
$this->service = app(OrderProcessingService::class);
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
}
public function test_create_from_temp_order_blocks_order_on_amount_drift(): void
{
$user = User::factory()->create();
// TempOrder에는 103000으로 저장
$tempOrder = $this->createTestTempOrder($user, 103000);
// 재계산 결과는 105000 (금액 변동)
$this->mockCalculationService($this->makeCalculationResult(105000));
$this->expectException(OrderAmountChangedException::class);
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
}
// ===== validatePaymentAmount 테스트 =====
/**
* 테스트용 주문 + 결제 레코드를 생성합니다.
*
* @param int $subtotal 소계
* @param int $shipping 배송비
* @param int $discount 할인액
*/
protected function createOrderWithPayment(
int $subtotal = 50000,
int $shipping = 3000,
int $discount = 0,
): Order {
$user = User::factory()->create();
$totalAmount = $subtotal - $discount + $shipping;
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
'subtotal_amount' => $subtotal,
'total_discount_amount' => $discount,
'total_product_coupon_discount_amount' => 0,
'total_order_coupon_discount_amount' => 0,
'total_code_discount_amount' => $discount,
'base_shipping_amount' => $shipping,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_shipping_amount' => $shipping,
'total_amount' => $totalAmount,
'total_due_amount' => $totalAmount,
'total_points_used_amount' => 0,
'total_deposit_used_amount' => 0,
'total_paid_amount' => 0,
]);
OrderPayment::factory()->create([
'order_id' => $order->id,
'payment_status' => PaymentStatusEnum::READY,
'payment_method' => PaymentMethodEnum::CARD,
'pg_provider' => 'tosspayments',
'paid_amount_local' => 0,
'paid_at' => null,
'transaction_id' => null,
'card_approval_number' => null,
]);
return $order;
}
public function test_validate_payment_amount_passes_when_amounts_match(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// 리플렉션으로 protected 메서드 테스트
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('validatePaymentAmount');
$method->setAccessible(true);
// 53000 = 50000 + 3000 - 0 → total_amount와 일치해야 함
$method->invoke($this->service, $order, 53000);
// 예외가 발생하지 않으면 성공
$this->assertTrue(true);
}
public function test_validate_payment_amount_throws_on_component_mismatch(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// DB에서 결제예정금액(total_due_amount)을 직접 변조 (컴포넌트 재합산과 불일치)
// 검증 1단계 기준 컬럼 = total_due_amount.
Order::withoutEvents(function () use ($order) {
$order->update(['total_due_amount' => 99999]);
});
$order->refresh();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('validatePaymentAmount');
$method->setAccessible(true);
$this->expectException(PaymentAmountMismatchException::class);
$method->invoke($this->service, $order, 99999);
}
/**
* 마일리지 사용 주문: 검증 기준 = total_due_amount(차감 후). 차감 후 금액이면 통과, 차감 전 금액이면 실패.
*
* 회귀: PG 검증 기준 컬럼을 total_due_amount 로 통일. total_points_used_amount 가 0 이 아닐 때
* 컴포넌트 재합산(마일리지 차감 포함) = total_due_amount 와 일치하고, 차감 전 금액(pgAmount)은 거부되어야 한다.
*/
public function test_validate_payment_amount_uses_post_mileage_due_amount(): void
{
// 상품 55,000 + 배송 0 / 마일리지 2,000 사용 → 차감 후 53,000
$order = $this->createOrderWithPayment(55000, 0, 0);
Order::withoutEvents(function () use ($order) {
$order->update([
'total_code_discount_amount' => 0,
'total_points_used_amount' => 2000,
'total_amount' => 53000,
'total_due_amount' => 53000,
]);
});
$order->refresh();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('validatePaymentAmount');
$method->setAccessible(true);
// 차감 후 53,000 으로 PG 콜백 → 통과
$method->invoke($this->service, $order, 53000);
$this->assertTrue(true);
// 차감 전 55,000 으로 PG 콜백 → 2단계에서 거부
$this->expectException(PaymentAmountMismatchException::class);
$method->invoke($this->service, $order, 55000);
}
public function test_validate_payment_amount_throws_on_pg_amount_mismatch(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('validatePaymentAmount');
$method->setAccessible(true);
// PG에서 다른 금액이 전달된 경우
$this->expectException(PaymentAmountMismatchException::class);
$method->invoke($this->service, $order, 99999);
}
/**
* base 통화 ≠ 결제 통화 조합에서 PG 청구액(결제 통화 환산) 검증이 정합해야 한다.
*
* 회귀 배경: buildPgPaymentData 가 PG 청구액을 결제 통화로 환산(resolveOrderPaymentChargeAmount)
* 하도록 바뀌었으나, validatePaymentAmount 2단계가 base(total_due_amount)를 직접 비교해
* base≠order_currency(예: base JPY 500 → 결제 KRW 4,750) 에서 "결제금액 불일치"로 오차단됐다.
* 검증 기준을 결제 통화 환산액으로 통일했으므로, 환산 청구액은 통과하고 base 금액은 거부되어야 한다.
*/
#[DataProvider('providePaymentCurrencyCombinations')]
public function test_validate_payment_amount_uses_payment_currency_charge(
string $baseCurrency,
string $orderCurrency,
float $baseDueAmount,
array $exchangeRates,
int $expectedPgAmount,
): void {
$order = $this->createOrderWithPayment(50000, 3000, 0);
// base≠order 스냅샷 + base 통화 total_due_amount 강제 주입
// (createOrderWithPayment 의 컴포넌트 검증 1단계를 통과시키려 컴포넌트도 base 기준으로 맞춘다)
Order::withoutEvents(function () use ($order, $baseCurrency, $orderCurrency, $baseDueAmount, $exchangeRates) {
$order->update([
'currency' => $orderCurrency,
'subtotal_amount' => $baseDueAmount,
'total_product_coupon_discount_amount' => 0,
'total_order_coupon_discount_amount' => 0,
'total_code_discount_amount' => 0,
'base_shipping_amount' => 0,
'extra_shipping_amount' => 0,
'shipping_discount_amount' => 0,
'total_points_used_amount' => 0,
'total_amount' => $baseDueAmount,
'total_due_amount' => $baseDueAmount,
'currency_snapshot' => [
'base_currency' => $baseCurrency,
'order_currency' => $orderCurrency,
'exchange_rates' => $exchangeRates,
],
]);
});
$order->refresh();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('validatePaymentAmount');
$method->setAccessible(true);
// 결제 통화 환산 청구액(PG 가 실제 청구하는 값)은 통과해야 한다.
$method->invoke($this->service, $order, $expectedPgAmount);
$this->assertTrue(true);
// base 금액(환산 전)이 환산 청구액과 다르면, base 금액을 PG 금액으로 주면 거부되어야 한다.
// (JPY→USD 처럼 base_unit/소수자리 보정으로 정수값이 우연히 같아지는 조합은 거부 단정 제외.)
$baseInt = (int) round($baseDueAmount);
if ($baseInt !== $expectedPgAmount) {
$this->expectException(PaymentAmountMismatchException::class);
$method->invoke($this->service, $order, $baseInt);
}
}
/**
* 검증 4조합: base/order 통화 전 경우의 수.
*
* @return array<string, array{0:string,1:string,2:float,3:array,4:int}>
*/
public static function providePaymentCurrencyCombinations(): array
{
// 환율/단위 규칙(스냅샷): KRW(decimals 0, base_unit 1000), USD(2, 1), JPY(0, base_unit 100)
$krw = ['rate' => '950', 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000];
$usd = ['rate' => '1', 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2, 'base_unit' => 1];
$jpy = ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'round', 'decimal_places' => 0, 'base_unit' => 100];
return [
// 기본 KRW + 유저 KRW 결제 (base==order): 53,000원 그대로
'base KRW / pay KRW' => [
'KRW', 'KRW', 53000.0,
['KRW' => $krw, 'USD' => $usd, 'JPY' => $jpy],
53000,
],
// 기본 USD + 유저 KRW 결제: base $6 → (6/1)*950 = 5,700 → floor 단위1 → 5,700원
'base USD / pay KRW' => [
'USD', 'KRW', 6.0,
['USD' => ['rate' => '1', 'decimal_places' => 2, 'base_unit' => 1], 'KRW' => $krw],
5700,
],
// 기본 JPY + 유저 USD 결제: base ¥500 → (500/100)*1 = $5.00 → minor 500
'base JPY / pay USD' => [
'JPY', 'USD', 500.0,
['JPY' => $jpy, 'USD' => $usd],
500,
],
// 기본 JPY + 유저 JPY 결제 (base==order): ¥500 그대로
'base JPY / pay JPY' => [
'JPY', 'JPY', 500.0,
['JPY' => $jpy],
500,
],
];
}
// ===== completePayment 확장 테스트 =====
public function test_complete_payment_with_pg_amount_validates_and_updates(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$result = $this->service->completePayment($order, [
'transaction_id' => 'pk_test_123',
'card_approval_number' => '87654321',
'card_number_masked' => '5432-****-****-1234',
'card_name' => '삼성카드',
'card_installment_months' => 3,
'is_interest_free' => true,
'receipt_url' => 'https://receipt.example.com',
'payment_device' => 'pc',
], 53000);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
// 결제 정보 확인
$payment = $result->payment;
$payment->refresh();
$this->assertEquals('pk_test_123', $payment->transaction_id);
$this->assertEquals('87654321', $payment->card_approval_number);
$this->assertEquals('5432-****-****-1234', $payment->card_number_masked);
$this->assertEquals('삼성카드', $payment->card_name);
$this->assertEquals(3, $payment->card_installment_months);
$this->assertTrue((bool) $payment->is_interest_free);
$this->assertEquals('https://receipt.example.com', $payment->receipt_url);
$this->assertEquals('pc', $payment->payment_device);
}
public function test_complete_payment_without_pg_amount_skips_validation(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// $pgAmount = null → 금액 검증 생략 (무통장입금 등)
$result = $this->service->completePayment($order, [
'transaction_id' => 'manual_confirm_123',
]);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
}
public function test_complete_payment_calls_hooks(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$beforeCalled = false;
$afterCalled = false;
HookManager::addAction('sirsoft-ecommerce.order.before_payment_complete', function () use (&$beforeCalled) {
$beforeCalled = true;
});
HookManager::addAction('sirsoft-ecommerce.order.after_payment_complete', function () use (&$afterCalled) {
$afterCalled = true;
});
$this->service->completePayment($order, [], 53000);
$this->assertTrue($beforeCalled, 'before_payment_complete 훅이 호출되어야 합니다');
$this->assertTrue($afterCalled, 'after_payment_complete 훅이 호출되어야 합니다');
}
// ===== completePayment 옵션 상태 동기화 회귀 테스트 (PG 결제완료 후 옵션이 주문대기에 갇히는 버그) =====
public function test_complete_payment_syncs_option_status_to_payment_complete(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$optionA = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$optionB = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$this->service->completePayment($order, [], 53000);
// 주문상품옵션도 결제완료로 동기화되어야 화면에 "주문대기"로 갇히지 않는다.
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $optionA->fresh()->option_status);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $optionB->fresh()->option_status);
}
public function test_complete_payment_does_not_resurrect_cancelled_option(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$activeOption = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$cancelledOption = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::CANCELLED,
]);
$this->service->completePayment($order, [], 53000);
// 활성 옵션만 결제완료로 전이, 취소 옵션은 보존되어야 한다.
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $activeOption->fresh()->option_status);
$this->assertEquals(OrderStatusEnum::CANCELLED, $cancelledOption->fresh()->option_status);
}
// ===== failPayment 테스트 =====
public function test_fail_payment_cancels_pending_order(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $order->order_status);
$result = $this->service->failPayment($order, 'PAY_PROCESS_CANCELED', '결제가 취소되었습니다.');
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order_status);
$meta = $result->order_meta;
$this->assertEquals('PAY_PROCESS_CANCELED', $meta['payment_failure_code']);
$this->assertEquals('결제가 취소되었습니다.', $meta['payment_failure_message']);
$this->assertArrayHasKey('payment_failed_at', $meta);
}
public function test_fail_payment_ignores_non_pending_order(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// 이미 결제 완료된 상태로 변경
$order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
$order->refresh();
$result = $this->service->failPayment($order, 'SOME_ERROR', '에러');
// 상태 변경 없이 원래 주문이 반환되어야 함
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
}
public function test_fail_payment_calls_payment_failed_hook(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$hookCalled = false;
$capturedCode = null;
HookManager::addAction('sirsoft-ecommerce.order.payment_failed', function ($o, $code, $message) use (&$hookCalled, &$capturedCode) {
$hookCalled = true;
$capturedCode = $code;
});
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
$this->assertTrue($hookCalled, 'payment_failed 훅이 호출되어야 합니다');
$this->assertEquals('USER_CANCEL', $capturedCode);
}
/**
* 결제 실패 시 옵션 상태도 CANCELLED 로 동기화 (B1 회귀)
*
* 기존: order_status 만 CANCELLED 로 바꾸고 option_status 는 PENDING_ORDER 에 잔존 →
* order_status=CANCELLED ↔ option_status=PENDING_ORDER 불일치.
*
* @scenario terminal_path=fail_payment, payment_method=dbank, option_mix=all_active, actor=member
*
* @effects fail_payment_syncs_option_status_to_cancelled
*/
public function test_fail_payment_syncs_option_status_to_cancelled(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$optionA = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$optionB = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
// 결제 실패로 주문이 취소되면 옵션도 취소 상태로 동기화되어야 한다.
$this->assertEquals(OrderStatusEnum::CANCELLED, $optionA->fresh()->option_status);
$this->assertEquals(OrderStatusEnum::CANCELLED, $optionB->fresh()->option_status);
}
/**
* 결제 실패 동기화가 이미 부분취소된 옵션을 되살리지 않음 (B1 syncExcludedValues)
*
* @scenario terminal_path=fail_payment, payment_method=dbank, option_mix=has_cancelled, actor=member
*
* @effects fail_payment_preserves_already_cancelled_options
*/
public function test_fail_payment_preserves_already_cancelled_options(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$activeOption = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
// 이미 취소된 옵션(부분취소로 일부 옵션이 cancelled 된 주문) — failPayment 가 보존해야 한다.
$cancelledOption = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::CANCELLED,
]);
$this->service->failPayment($order, 'USER_CANCEL', '사용자 취소');
// 활성 옵션만 CANCELLED 로 전이, 이미 취소된 옵션은 보존(syncExcludedValues).
$this->assertEquals(OrderStatusEnum::CANCELLED, $activeOption->fresh()->option_status);
$this->assertEquals(OrderStatusEnum::CANCELLED, $cancelledOption->fresh()->option_status);
}
/**
* 이미 결제 완료(payment_status=PAID)된 주문은 failPayment 가 무시한다 (race 회귀).
*
* 배경: 카드(PG) 주문은 승인 직전까지 order_status=PENDING_ORDER 이다. PG 승인 콜백
* (completePayment)과 결제창 닫힘 보고(failPayment)가 거의 동시에 도착하면, failPayment 가
* order_status 만 보는 기존 가드(isBeforePayment)를 통과해 옵션을 CANCELLED 로 덮을 수 있다.
* 이후 order_status 는 completePayment 가 PAYMENT_COMPLETE 로 덮지만, 옵션은 syncExcludedValues
* 보호로 되살아나지 못해 "주문=결제완료 / 옵션=취소" 모순이 고착된다.
* → payment_status=PAID 면 결제는 이미 성공한 것이므로 failPayment 를 무시해야 한다.
*
* @scenario terminal_path=fail_payment, payment_method=card, payment_status=paid, option_mix=all_active, actor=member
*
* @effects fail_payment_ignored_when_payment_already_paid
*/
public function test_fail_payment_ignores_order_when_payment_already_paid(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// 승인 콜백이 payment 를 먼저 PAID 로 갱신한 race 상태 재현 (order_status 는 아직 PENDING_ORDER).
$order->payment->update(['payment_status' => PaymentStatusEnum::PAID]);
$order->refresh();
$optionA = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$optionB = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫힘');
// 결제가 이미 성공했으므로 주문/옵션 모두 취소되지 않아야 한다.
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $result->order_status);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $optionA->fresh()->option_status);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $optionB->fresh()->option_status);
}
/**
* payment_status=PAID 가드는 결제실패 메타도 기록하지 않는다 (race 회귀).
*
* @scenario terminal_path=fail_payment, payment_method=card, payment_status=paid, option_mix=all_active, actor=member
*
* @effects fail_payment_ignored_when_payment_already_paid
*/
public function test_fail_payment_does_not_record_failure_meta_when_paid(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$order->payment->update(['payment_status' => PaymentStatusEnum::PAID]);
$order->refresh();
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫힘');
$meta = $result->order_meta ?? [];
$this->assertArrayNotHasKey('payment_failure_code', $meta);
$this->assertArrayNotHasKey('payment_failed_at', $meta);
}
// ===== confirmManualDeposit (무통장 입금확인) 테스트 (B2) =====
/**
* 무통장 미결제 주문 생성 (입금확인 테스트용)
*/
private function createDbankOrder(int $subtotal = 50000, int $shipping = 3000): Order
{
$order = $this->createOrderWithPayment($subtotal, $shipping, 0);
$order->update(['order_status' => OrderStatusEnum::PENDING_PAYMENT]);
$order->payment->update(['payment_method' => PaymentMethodEnum::DBANK]);
return $order->fresh();
}
/**
* 무통장 입금확인 → completePayment 위임으로 옵션상태 결제완료 동기화 (B2)
*
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
*
* @effects manual_deposit_confirm_syncs_option_status_via_complete_payment, manual_deposit_confirm_records_depositor_name
*/
public function test_confirm_manual_deposit_syncs_option_status_and_records_depositor(): void
{
$order = $this->createDbankOrder(50000, 3000);
$option = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
// markOrderComplete=true: 입금 기록 + 결제완료 전이
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', true);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->fresh()->order_status);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $option->fresh()->option_status);
$this->assertEquals('홍길동', $order->fresh()->payment->depositor_name);
$this->assertEquals(PaymentStatusEnum::PAID, $order->fresh()->payment->payment_status);
}
/**
* 무통장 입금확인 — markOrderComplete=false: 입금(payment)만 기록, 주문 상태는 전이 안 함
*
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, actor=admin
*
* @effects manual_deposit_records_payment_only_without_order_transition
*/
public function test_confirm_manual_deposit_records_payment_only_when_not_marking_complete(): void
{
$order = $this->createDbankOrder(50000, 3000);
$option = OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', false);
// 결제 레코드는 입금완료로 기록
$this->assertEquals(PaymentStatusEnum::PAID, $result->payment->payment_status);
$this->assertNotNull($result->payment->paid_at);
$this->assertEquals('홍길동', $result->payment->depositor_name);
// 주문/옵션 상태는 전이되지 않음 (결제완료 부수효과 미발생)
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $option->fresh()->option_status);
}
/**
* 무통장 입금확인 — 주문은 이미 결제완료(order_status)인데 payment 가 미입금(ready)인
* 불일치 주문: payment 만 PAID 로 정합화하고 결제완료 부수효과는 재실행하지 않는다.
*
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, actor=admin
*
* @effects manual_deposit_reconciles_payment_only_for_already_completed_order
*/
public function test_confirm_manual_deposit_reconciles_payment_for_already_completed_order(): void
{
$order = $this->createDbankOrder(50000, 3000);
// 관리자가 주문 상태만 결제완료로 바꿔 둔 불일치 상황 (payment 는 여전히 ready)
$order->update(['order_status' => OrderStatusEnum::PAYMENT_COMPLETE]);
$this->assertEquals(PaymentStatusEnum::READY, $order->fresh()->payment->payment_status);
// markOrderComplete=true 여도 이미 결제완료이므로 completePayment 전이는 건너뛰고 payment 만 정합
$result = $this->service->confirmManualDeposit($order, 53000, '홍길동', true);
$this->assertEquals(PaymentStatusEnum::PAID, $result->payment->payment_status);
$this->assertNotNull($result->payment->paid_at);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
}
/**
* 무통장 입금확인: 입금액이 결제예정금액과 불일치하면 422(PaymentAmountMismatchException) (B2)
*
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
*
* @effects manual_deposit_confirm_rejects_amount_mismatch_422
*/
public function test_confirm_manual_deposit_rejects_amount_mismatch(): void
{
$order = $this->createDbankOrder(50000, 3000);
OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$this->expectException(PaymentAmountMismatchException::class);
$this->service->confirmManualDeposit($order, 10000, '홍길동');
// 결제완료로 전이되지 않아야 한다 (예외 전 검증 단계에서 차단)
$this->assertNotEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->fresh()->order_status);
}
/**
* 무통장 입금확인 진입부에서 IDV 가드 훅(payment.before_confirm_deposit)이 발화 (A8/B2)
*
* @scenario terminal_path=manual_deposit_confirm, payment_method=dbank, option_mix=all_active, actor=admin
*
* @effects manual_deposit_confirm_syncs_option_status_via_complete_payment
*/
public function test_confirm_manual_deposit_fires_before_confirm_deposit_hook(): void
{
$order = $this->createDbankOrder(50000, 3000);
OrderOption::factory()->create([
'order_id' => $order->id,
'option_status' => OrderStatusEnum::PENDING_ORDER,
]);
$hookFired = false;
HookManager::addAction('sirsoft-ecommerce.payment.before_confirm_deposit', function () use (&$hookFired) {
$hookFired = true;
});
$this->service->confirmManualDeposit($order, 53000, '홍길동');
$this->assertTrue($hookFired, 'payment.before_confirm_deposit 훅이 발화되어야 합니다');
}
// ===== findByOrderNumber 테스트 =====
// ===== recordPaymentCancellation 테스트 =====
public function test_record_payment_cancellation_updates_payment_status(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$result = $this->service->recordPaymentCancellation($order);
$payment = $result->payment;
$payment->refresh();
$this->assertEquals(PaymentStatusEnum::CANCELLED->value, $payment->payment_status->value);
$this->assertNotNull($payment->cancelled_at);
}
public function test_record_payment_cancellation_appends_cancel_history(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$result = $this->service->recordPaymentCancellation($order, 'USER_CANCEL', '사용자가 결제를 취소했습니다.');
$payment = $result->payment;
$payment->refresh();
$cancelHistory = $payment->cancel_history;
$this->assertIsArray($cancelHistory);
$this->assertCount(1, $cancelHistory);
$this->assertEquals('USER_CANCEL', $cancelHistory[0]['cancel_code']);
$this->assertEquals('사용자가 결제를 취소했습니다.', $cancelHistory[0]['cancel_message']);
$this->assertArrayHasKey('cancelled_at', $cancelHistory[0]);
}
public function test_record_payment_cancellation_appends_to_existing_history(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// 1차 취소
$this->service->recordPaymentCancellation($order);
// 2차 취소
$result = $this->service->recordPaymentCancellation($order->fresh());
$payment = $result->payment;
$payment->refresh();
$cancelHistory = $payment->cancel_history;
$this->assertCount(2, $cancelHistory);
}
public function test_record_payment_cancellation_returns_order_when_no_payment(): void
{
$user = User::factory()->create();
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PENDING_ORDER,
]);
// payment 없이 호출 → 오류 없이 반환
$result = $this->service->recordPaymentCancellation($order);
$this->assertEquals($order->id, $result->id);
}
// ===== completePayment 임시주문 정리 테스트 =====
public function test_complete_payment_deletes_temp_order(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// 임시주문 생성
$user = $order->user;
$tempOrder = TempOrder::create([
'user_id' => $user->id,
'cart_key' => 'test-cart-key',
'items' => [],
'calculation_result' => ['summary' => ['final_amount' => 53000]],
'expires_at' => now()->addMinutes(30),
]);
$this->assertDatabaseHas('ecommerce_temp_orders', ['id' => $tempOrder->id]);
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_123',
], 53000);
// completePayment 후 임시주문 삭제 확인
$this->assertDatabaseMissing('ecommerce_temp_orders', ['id' => $tempOrder->id]);
}
// ===== findByOrderNumber 테스트 =====
public function test_find_by_order_number_returns_order(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
$found = $this->service->findByOrderNumber($order->order_number);
$this->assertNotNull($found);
$this->assertEquals($order->id, $found->id);
}
public function test_find_by_order_number_returns_null_for_non_existent(): void
{
$found = $this->service->findByOrderNumber('NON_EXISTENT_ORDER_NUMBER');
$this->assertNull($found);
}
// ===== 재고 차감 타이밍 (stock_deduction_timing) 테스트 =====
/**
* StockService와 EcommerceSettingsService를 mock하여 재고 차감 타이밍을 제어합니다.
*
* @param string $timing 재고 차감 타이밍 ('order_placed', 'payment_complete', 'none')
* @return MockObject StockService mock
*/
protected function mockStockAndSettingsForTiming(string $timing): MockObject
{
$settingsMock = $this->createMock(EcommerceSettingsService::class);
$settingsMock->method('getStockDeductionTiming')->willReturn($timing);
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
$stockMock = $this->createMock(StockService::class);
$this->app->instance(StockService::class, $stockMock);
return $stockMock;
}
public function test_create_from_temp_order_deducts_stock_for_order_placed_timing(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// StockService mock: deductStock이 정확히 1회 호출되어야 함
$stockMock = $this->mockStockAndSettingsForTiming('order_placed');
$stockMock->expects($this->once())->method('deductStock');
$this->mockCalculationService($this->makeCalculationResult(103000));
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'dbank',
103000
);
}
public function test_create_from_temp_order_does_not_deduct_stock_for_payment_complete_timing(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// StockService mock: deductStock이 호출되지 않아야 함
$stockMock = $this->mockStockAndSettingsForTiming('payment_complete');
$stockMock->expects($this->never())->method('deductStock');
$this->mockCalculationService($this->makeCalculationResult(103000));
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
}
public function test_create_from_temp_order_does_not_deduct_stock_for_none_timing(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
// StockService mock: deductStock이 호출되지 않아야 함
$stockMock = $this->mockStockAndSettingsForTiming('none');
$stockMock->expects($this->never())->method('deductStock');
$this->mockCalculationService($this->makeCalculationResult(103000));
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'dbank',
103000
);
}
// ──────────────────────────────────────────────
// 관리자 신규주문 알림 발송 시점 (order.after_admin_notify)
// 카드(PG): 결제완료 시점 / 무통장(비-PG): 주문 생성 시점
// ──────────────────────────────────────────────
/**
* order.after_admin_notify 발화 횟수를 카운트하는 리스너를 등록합니다.
*
* @return \Closure 발화 횟수를 반환하는 getter
*/
private function countAdminNotify(): \Closure
{
$count = 0;
HookManager::addAction('sirsoft-ecommerce.order.after_admin_notify', function () use (&$count) {
$count++;
}, 99);
return function () use (&$count) {
return $count;
};
}
/**
* 카드 결제수단에 PG 공급자가 설정된 환경을 시뮬레이션합니다.
*
* orderRequiresPgPayment 는 determinePgProvider → SettingsService 조회 결과로 PG 필요 여부를
* 판정한다. 테스트 환경엔 활성 PG 플러그인이 없어 'none' 이 되므로, 설정을 목킹해 'kginicis' 를
* 반환하게 한다. mockCalculationService 호출 전에 설정 인스턴스를 바인딩해야 한다.
*/
private function mockCardPgProvider(): void
{
$settingsMock = $this->createMock(EcommerceSettingsService::class);
$settingsMock->method('getPaymentMethodConfig')->willReturn(['pg_provider' => 'kginicis']);
$settingsMock->method('getSetting')->willReturnCallback(
fn ($key, $default = null) => $key === 'order_settings.default_pg_provider' ? 'kginicis' : $default
);
$settingsMock->method('getStockDeductionTiming')->willReturn('payment_complete');
$settingsMock->method('getMileageDeductionTiming')->willReturn('payment_complete');
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
}
public function test_card_order_does_not_fire_admin_notify_at_creation(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
$this->mockCardPgProvider();
$this->mockCalculationService($this->makeCalculationResult(103000));
$getCount = $this->countAdminNotify();
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
103000
);
// 카드(PG) 주문은 생성 시점(pending_order)에 관리자 알림을 발화하지 않는다.
$this->assertSame(0, $getCount());
}
public function test_dbank_order_fires_admin_notify_at_creation(): void
{
$user = User::factory()->create();
$tempOrder = $this->createTestTempOrder($user);
$this->mockCalculationService($this->makeCalculationResult(103000));
$getCount = $this->countAdminNotify();
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Test', 'phone' => '010-0000-0000', 'email' => 'test@test.com'],
['recipient_name' => 'Test', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'dbank',
103000
);
// 무통장(비-PG) 주문은 생성 시점(입금 전이라도)에 관리자 알림을 발화한다.
$this->assertSame(1, $getCount());
}
public function test_card_order_fires_admin_notify_on_payment_complete(): void
{
// 일관된 카드 주문 fixture(total_due=53000) — completePayment 금액 재검증 통과
$order = $this->createOrderWithPayment();
$getCount = $this->countAdminNotify();
// 결제완료 시점에 1회 발화 (PG 콜백 실청구액 = 주문 결제예정액)
$this->service->completePayment($order, ['transaction_id' => 'TX-TEST'], (int) $order->total_due_amount);
$this->assertSame(1, $getCount());
}
// ──────────────────────────────────────────────
// 미지원 결제 통화 차단 (assertPaymentCurrencyChargeable)
// ──────────────────────────────────────────────
public function test_assert_payment_currency_throws_for_zero_rate_currency(): void
{
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
$method->setAccessible(true);
$snapshot = [
'base_currency' => 'USD',
'order_currency' => 'CNY',
'exchange_rates' => [
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
'CNY' => ['rate' => 0, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
],
];
$this->expectException(UnsupportedPaymentCurrencyException::class);
$method->invoke($this->service, $snapshot, 6.0);
}
public function test_assert_payment_currency_throws_when_converted_amount_is_zero(): void
{
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
$method->setAccessible(true);
// base $0.0001 × JPY 환율 → 환산 후 0 (PG 최소금액 미만)
$snapshot = [
'base_currency' => 'USD',
'order_currency' => 'JPY',
'exchange_rates' => [
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
'JPY' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
],
];
$this->expectException(UnsupportedPaymentCurrencyException::class);
$method->invoke($this->service, $snapshot, 0.0001);
}
public function test_assert_payment_currency_passes_for_valid_currency(): void
{
$method = new \ReflectionMethod($this->service, 'assertPaymentCurrencyChargeable');
$method->setAccessible(true);
$snapshot = [
'base_currency' => 'USD',
'order_currency' => 'KRW',
'exchange_rates' => [
'KRW' => ['rate' => 1176470, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0],
'USD' => ['rate' => 1, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'decimal_places' => 2],
],
];
// 예외 없이 통과
$method->invoke($this->service, $snapshot, 6.0);
$this->assertTrue(true);
}
public function test_complete_payment_deducts_stock_for_payment_complete_timing(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// StockService mock: deductStock이 정확히 1회 호출되어야 함
$stockMock = $this->mockStockAndSettingsForTiming('payment_complete');
$stockMock->expects($this->once())->method('deductStock');
$this->service = app(OrderProcessingService::class);
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_stock',
], 53000);
}
public function test_complete_payment_does_not_deduct_stock_for_order_placed_timing(): void
{
$order = $this->createOrderWithPayment(50000, 3000, 0);
// StockService mock: deductStock이 호출되지 않아야 함 (이미 주문 시 차감됨)
$stockMock = $this->mockStockAndSettingsForTiming('order_placed');
$stockMock->expects($this->never())->method('deductStock');
$this->service = app(OrderProcessingService::class);
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_no_stock',
], 53000);
}
// ===== completePayment 배송지 자동 저장 테스트 =====
public function test_complete_payment_saves_shipping_address_when_meta_flag_set(): void
{
$order = $this->createOrderWithPayment();
$order->update([
'order_meta' => [
'save_shipping_address' => true,
'shipping_info_for_save' => [
'recipient_name' => '김철수',
'recipient_phone' => '010-9876-5432',
'country_code' => 'KR',
'zipcode' => '12345',
'address' => '서울시 강남구 테헤란로 123',
'address_detail' => '101동 1001호',
],
],
]);
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_addr_save',
], 53000);
$this->assertDatabaseHas('ecommerce_user_addresses', [
'user_id' => $order->user_id,
'recipient_name' => '김철수',
'zipcode' => '12345',
]);
// order_meta에서 플래그 제거 확인
$order->refresh();
$this->assertFalse($order->order_meta['save_shipping_address'] ?? false);
$this->assertArrayNotHasKey('shipping_info_for_save', $order->order_meta ?? []);
}
public function test_complete_payment_does_not_save_address_when_no_meta_flag(): void
{
$order = $this->createOrderWithPayment();
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_no_flag',
], 53000);
$this->assertDatabaseMissing('ecommerce_user_addresses', [
'user_id' => $order->user_id,
]);
}
public function test_complete_payment_does_not_save_address_for_guest_order(): void
{
$order = $this->createOrderWithPayment();
// user_id를 null로 변경 (비회원 주문 시뮬레이션)
Order::withoutEvents(function () use ($order) {
$order->update([
'user_id' => null,
'order_meta' => [
'save_shipping_address' => true,
'shipping_info_for_save' => [
'recipient_name' => '비회원',
'recipient_phone' => '010-0000-0000',
'zipcode' => '99999',
'address' => '서울시',
],
],
]);
});
$order->refresh();
$this->service->completePayment($order, [
'transaction_id' => 'test_tx_guest',
], 53000);
$this->assertEquals(0, UserAddress::count());
}
public function test_complete_payment_succeeds_even_if_address_save_fails(): void
{
$order = $this->createOrderWithPayment();
$order->update([
'order_meta' => [
'save_shipping_address' => true,
'shipping_info_for_save' => [], // 빈 데이터 (저장 시 예외 가능)
],
]);
// 결제 완료는 정상 처리되어야 함 (예외 미전파)
$result = $this->service->completePayment($order, [
'transaction_id' => 'test_tx_addr_fail',
], 53000);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
}
// ===== 장바구니 처리 테스트 =====
/**
* 테스트용 Cart 레코드를 생성합니다.
*
* @param int $userId 사용자 ID
* @param int $quantity 수량
* @param Product|null $product 상품 (null이면 새로 생성)
* @param ProductOption|null $option 옵션 (null이면 새로 생성)
*/
protected function createTestCart(
int $userId,
int $quantity = 1,
?Product $product = null,
?ProductOption $option = null
): Cart {
$product = $product ?? Product::factory()->create();
$option = $option ?? ProductOption::factory()->create(['product_id' => $product->id]);
return Cart::create([
'user_id' => $userId,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => $quantity,
]);
}
public function test_build_order_meta에_cart_items_포함(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$option1 = ProductOption::factory()->create(['product_id' => $product->id]);
$option2 = ProductOption::factory()->create(['product_id' => $product->id]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
['cart_id' => 10, 'product_id' => $product->id, 'product_option_id' => $option1->id, 'quantity' => 3],
['cart_id' => 20, 'product_id' => $product->id, 'product_option_id' => $option2->id, 'quantity' => 1],
])
->withCalculationResult([
'summary' => ['final_amount' => 50000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('buildOrderMeta');
$method->setAccessible(true);
$meta = $method->invoke($this->service, $tempOrder);
$this->assertArrayHasKey('cart_items', $meta);
$this->assertCount(2, $meta['cart_items']);
$this->assertEquals(10, $meta['cart_items'][0]['cart_id']);
$this->assertEquals(3, $meta['cart_items'][0]['quantity']);
$this->assertEquals(20, $meta['cart_items'][1]['cart_id']);
$this->assertEquals(1, $meta['cart_items'][1]['quantity']);
}
public function test_build_order_meta_cart_id_없는_아이템은_제외(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$option = ProductOption::factory()->create(['product_id' => $product->id]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
['product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 1],
])
->withCalculationResult([
'summary' => ['final_amount' => 50000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('buildOrderMeta');
$method->setAccessible(true);
$meta = $method->invoke($this->service, $tempOrder);
$this->assertArrayHasKey('cart_items', $meta);
$this->assertEmpty($meta['cart_items']);
}
public function test_clear_ordered_cart_items_장바구니_수량과_주문_수량_같으면_삭제(): void
{
$user = User::factory()->create();
$cart = $this->createTestCart($user->id, 3);
$order = Order::factory()->create([
'user_id' => $user->id,
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
}
public function test_clear_ordered_cart_items_장바구니_수량이_주문보다_크면_차감(): void
{
$user = User::factory()->create();
$cart = $this->createTestCart($user->id, 5);
$order = Order::factory()->create([
'user_id' => $user->id,
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart->id, 'quantity' => 2]);
}
public function test_clear_ordered_cart_items_장바구니_수량이_주문보다_작으면_삭제(): void
{
$user = User::factory()->create();
$cart = $this->createTestCart($user->id, 2);
$order = Order::factory()->create([
'user_id' => $user->id,
'order_meta' => ['cart_items' => [['cart_id' => $cart->id, 'quantity' => 3]]],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
}
public function test_clear_ordered_cart_items_cart_items_비어있으면_스킵(): void
{
$order = Order::factory()->create([
'order_meta' => ['cart_items' => []],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
// 예외 없이 정상 완료
$this->assertTrue(true);
}
public function test_clear_ordered_cart_items_이미_삭제된_장바구니는_무시(): void
{
$order = Order::factory()->create([
'order_meta' => ['cart_items' => [['cart_id' => 999999, 'quantity' => 1]]],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
// 존재하지 않는 cart_id여도 예외 없이 정상 완료 (멱등성)
$this->assertTrue(true);
}
public function test_clear_ordered_cart_items_복수_아이템_혼합_처리(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$option1 = ProductOption::factory()->create(['product_id' => $product->id]);
$option2 = ProductOption::factory()->create(['product_id' => $product->id]);
$option3 = ProductOption::factory()->create(['product_id' => $product->id]);
$cart1 = $this->createTestCart($user->id, 5, $product, $option1);
$cart2 = $this->createTestCart($user->id, 3, $product, $option2);
$cart3 = $this->createTestCart($user->id, 1, $product, $option3);
$order = Order::factory()->create([
'user_id' => $user->id,
'order_meta' => ['cart_items' => [
['cart_id' => $cart1->id, 'quantity' => 2],
['cart_id' => $cart2->id, 'quantity' => 3],
['cart_id' => $cart3->id, 'quantity' => 5],
]],
]);
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('clearOrderedCartItems');
$method->setAccessible(true);
$method->invoke($this->service, $order);
// cart1: 수량 5, 주문 2 → 잔여 3
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart1->id, 'quantity' => 3]);
// cart2: 수량 3, 주문 3 → 삭제
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart2->id]);
// cart3: 수량 1, 주문 5 → 삭제
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart3->id]);
}
public function test_order_placed_타이밍에서_장바구니_아이템_삭제(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$option = ProductOption::factory()->create(['product_id' => $product->id]);
$cart = $this->createTestCart($user->id, 2, $product, $option);
// order_placed 타이밍 설정
$settingsMock = $this->createMock(EcommerceSettingsService::class);
$settingsMock->method('getStockDeductionTiming')->willReturn('order_placed');
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
$stockMock = $this->createMock(StockService::class);
$stockMock->expects($this->once())->method('deductStock');
$this->app->instance(StockService::class, $stockMock);
$this->service = app(OrderProcessingService::class);
$this->mockCalculationService($this->makeCalculationResult(103000));
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
['cart_id' => $cart->id, 'product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 2],
])
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$this->service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '123동'],
'dbank',
103000,
null,
'홍길동'
);
// order_placed 타이밍 → 재고 차감 + 장바구니 삭제
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
}
public function test_payment_complete_타이밍에서_create_from_temp_order_장바구니_미삭제(): void
{
$user = User::factory()->create();
$product = Product::factory()->create();
$option = ProductOption::factory()->create(['product_id' => $product->id]);
$cart = $this->createTestCart($user->id, 2, $product, $option);
// payment_complete 타이밍 설정
$settingsMock = $this->createMock(EcommerceSettingsService::class);
$settingsMock->method('getStockDeductionTiming')->willReturn('payment_complete');
$this->app->instance(EcommerceSettingsService::class, $settingsMock);
$this->service = app(OrderProcessingService::class);
$this->mockCalculationService($this->makeCalculationResult(103000));
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withItems([
['cart_id' => $cart->id, 'product_id' => $product->id, 'product_option_id' => $option->id, 'quantity' => 2],
])
->withCalculationResult([
'summary' => ['final_amount' => 103000],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구', 'address_detail' => '123동'],
'card',
103000
);
// payment_complete 타이밍 → createFromTempOrder에서는 장바구니 미삭제
$this->assertDatabaseHas('ecommerce_carts', ['id' => $cart->id]);
// order_meta에 cart_items 저장 확인 (나중에 completePayment에서 사용)
$this->assertNotEmpty($order->order_meta['cart_items']);
}
public function test_complete_payment_시_장바구니_아이템_삭제(): void
{
$user = User::factory()->create();
$cart = $this->createTestCart($user->id, 2);
$order = $this->createOrderWithPayment();
$order->update([
'order_meta' => array_merge($order->order_meta ?? [], [
'cart_items' => [['cart_id' => $cart->id, 'quantity' => 2]],
]),
]);
$result = $this->service->completePayment($order, [
'transaction_id' => 'test_tx_cart_clear',
], $order->total_amount);
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $result->order_status);
$this->assertDatabaseMissing('ecommerce_carts', ['id' => $cart->id]);
}
public function test_complete_payment_stores_paid_amount_local_in_order_currency_when_cross_currency(): void
{
// base JPY 주문을 KRW 로 결제: paid_amount_local 은 결제 통화(KRW) 환산액이어야 하고,
// paid_amount_base 는 base(JPY) 진실값이어야 한다. base 값을 그대로 local 에 넣던 회귀를 차단.
$order = $this->createOrderWithPayment(200, 0, 0); // base JPY 200
$order->update([
'currency' => 'KRW',
'total_due_amount' => 200,
'currency_snapshot' => [
'base_currency' => 'JPY',
'order_currency' => 'KRW',
'base_unit' => 100,
'exchange_rates' => [
'JPY' => ['rate' => 1, 'rounding_unit' => '1', 'rounding_method' => 'round', 'decimal_places' => 0, 'base_unit' => 100],
'KRW' => ['rate' => 950, 'rounding_unit' => '1', 'rounding_method' => 'floor', 'decimal_places' => 0, 'base_unit' => 1000],
],
],
]);
// 결제 통화(KRW) 청구액 = (200/100)×950 = 1900
$result = $this->service->completePayment($order->fresh(), [], 1900);
$payment = $result->payment()->first();
$this->assertSame('KRW', $payment->currency);
// paid_amount_local = 결제 통화 환산액 1900 (base 200 이 아님)
$this->assertEqualsWithDelta(1900, (float) $payment->paid_amount_local, 0.001, 'paid_amount_local 은 결제 통화(KRW) 환산액이어야 한다.');
// paid_amount_base = base 진실값 200
$this->assertEqualsWithDelta(200, (float) $payment->paid_amount_base, 0.001, 'paid_amount_base 는 base(JPY) 진실값이어야 한다.');
// mc_total_paid_amount[order_currency] 와 일치
$this->assertEqualsWithDelta(
(float) ($result->fresh()->mc_total_paid_amount['KRW'] ?? 0),
(float) $payment->paid_amount_local,
0.001,
'paid_amount_local 은 mc_total_paid_amount[order_currency] 와 정합해야 한다.'
);
}
// ===== 구매 대상 제한 최종 차단 통합 테스트 =====
/**
* 구매 대상 제한이 걸린 상품을 담은 TempOrder를 생성합니다.
*
* @param User $user 사용자
* @param int $productId 주문 항목의 상품 ID
* @return TempOrder
*/
protected function createRestrictedTempOrder(User $user, int $productId)
{
return TempOrderFactory::new()
->forUser($user)
->withItems([
['cart_id' => 1, 'product_id' => $productId, 'product_option_id' => 1, 'quantity' => 1],
])
->withCalculationResult([
'summary' => [
'subtotal' => 100000, 'total_discount' => 0, 'product_coupon_discount' => 0,
'code_discount' => 0, 'total_shipping' => 3000, 'final_amount' => 103000,
'taxable_amount' => 93636, 'tax_free_amount' => 0, 'points_used' => 0,
],
'items' => [], 'shippings' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
}
/**
* @return array{0: array, 1: array} 주문자 정보, 배송지 정보
*/
protected function ordererAndShipping(): array
{
return [
['name' => '홍길동', 'phone' => '010-1234-5678', 'email' => 'test@example.com'],
['recipient_name' => '홍길동', 'recipient_phone' => '010-1234-5678', 'zipcode' => '12345', 'address' => '서울시 강남구 테헤란로', 'address_detail' => '123동 456호'],
];
}
public function test_허용_역할_없는_회원은_제한_상품_주문_차단(): void
{
$allowedRole = Role::create(['identifier' => 'vip-test', 'name' => ['ko' => 'VIP', 'en' => 'VIP']]);
// 사용자는 allowedRole 미보유
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create([
'purchase_restriction' => 'restricted',
'allowed_roles' => [$allowedRole->id],
]);
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
[$orderer, $shipping] = $this->ordererAndShipping();
$this->expectException(CartUnavailableException::class);
$this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
}
public function test_허용_역할_보유_회원은_제한_상품_주문_가능(): void
{
$allowedRole = Role::create(['identifier' => 'vip-test', 'name' => ['ko' => 'VIP', 'en' => 'VIP']]);
$user = User::factory()->create();
$user->roles()->attach($allowedRole->id);
$this->actingAs($user->fresh());
$product = Product::factory()->create([
'purchase_restriction' => 'restricted',
'allowed_roles' => [$allowedRole->id],
]);
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
[$orderer, $shipping] = $this->ordererAndShipping();
$this->mockCalculationService($this->makeCalculationResult(103000));
$order = $this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
$this->assertInstanceOf(Order::class, $order);
}
public function test_제한_없음_상품은_회원_주문_정상_생성(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$product = Product::factory()->create([
'purchase_restriction' => 'none',
'allowed_roles' => [],
]);
$tempOrder = $this->createRestrictedTempOrder($user, $product->id);
[$orderer, $shipping] = $this->ordererAndShipping();
$this->mockCalculationService($this->makeCalculationResult(103000));
$order = $this->service->createFromTempOrder($tempOrder, $orderer, $shipping, 'card', 103000);
$this->assertInstanceOf(Order::class, $order);
}
/**
* §3.4 회귀: 옵션 다통화 빌더가 mc_subtotal_earned_points_amount 를 포함해야 한다.
*
* 결함: buildOptionMultiCurrency 에 적립값 변환이 빠져 mc_ 적립 컬럼이 항상 NULL.
*/
public function test_build_option_multi_currency_includes_earned_points(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('buildOptionMultiCurrency');
$method->setAccessible(true);
$item = (object) [
'unitPrice' => 10000,
'subtotal' => 10000,
'productCouponDiscountAmount' => 0,
'orderCouponDiscountShare' => 0,
'codeDiscountAmount' => 0,
'pointsUsedShare' => 0,
'pointsEarning' => 500,
'taxableAmount' => 10000,
'taxFreeAmount' => 0,
'finalAmount' => 10000,
];
$snapshot = [
'base_currency' => 'KRW',
'exchange_rates' => [
'KRW' => 1,
'USD' => ['rate' => 0.75, 'rounding_unit' => '0.01', 'rounding_method' => 'round'],
],
];
$mc = $method->invoke($this->service, $item, $snapshot);
// 적립 다통화 컬럼이 존재하고 적립값(500)이 기준통화에 반영되어야 함
$this->assertArrayHasKey('mc_subtotal_earned_points_amount', $mc, '적립 다통화 컬럼이 빌더에 포함되어야 합니다.');
$this->assertSame(500, $mc['mc_subtotal_earned_points_amount']['KRW'] ?? null);
}
// ===== 전액 마일리지 결제 (결제액 0원) — determineInitialStatus / 자동 완료 =====
public function test_determine_initial_status_returns_payment_complete_for_zero_amount(): void
{
$reflection = new \ReflectionClass($this->service);
$method = $reflection->getMethod('determineInitialStatus');
$method->setAccessible(true);
// 결제수단 무관(card/vbank/dbank) — finalAmount 0 이면 결제완료
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'card', 0));
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'vbank', 0));
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $method->invoke($this->service, 'dbank', 0));
// finalAmount > 0 이면 기존 분기 유지
$this->assertEquals(OrderStatusEnum::PENDING_ORDER, $method->invoke($this->service, 'card', 50000));
$this->assertEquals(OrderStatusEnum::PENDING_PAYMENT, $method->invoke($this->service, 'vbank', 50000));
}
public function test_create_from_temp_order_full_mileage_payment_marks_payment_complete(): void
{
$this->allowAnyMileageUsage();
$user = User::factory()->create();
// 결제액 전액(100,000)을 마일리지로 충당 — FIFO 차감용 잔액 시드
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => 100000,
'remaining_amount' => 100000,
'balance_after' => 100000,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withUsePoints(100000)
->withCalculationResult([
'summary' => ['final_amount' => 0],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
// 재계산: payment_amount 100,000 전액 마일리지 사용 → final_amount 0
$this->mockCalculationService($this->makeCalculationResult(0, [
'subtotal' => 100000,
'total_shipping' => 0,
'taxable_amount' => 100000,
'payment_amount' => 100000,
'points_used' => 100000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => '전액마일', 'phone' => '010-0000-0000', 'email' => 'allmileage@test.com'],
['recipient_name' => '전액마일', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'Test', 'address_detail' => 'Test'],
'card',
0
);
$order->refresh()->load('payment');
// 결제액 0 → 즉시 결제완료
$this->assertEquals(OrderStatusEnum::PAYMENT_COMPLETE, $order->order_status, '전액 마일리지 결제는 결제완료 상태여야 합니다.');
$this->assertNotNull($order->paid_at, '결제완료 시각이 기록되어야 합니다.');
$this->assertEquals(0, (int) $order->total_due_amount, '미결제 잔액(입금 필요액)은 0이어야 합니다.');
$this->assertEquals(0, (int) $order->total_paid_amount, 'PG/현금 결제액은 0이어야 합니다(전액 마일리지).');
$this->assertEquals(100000, (int) $order->total_points_used_amount, '마일리지 사용액이 기록되어야 합니다.');
// 결제 레코드도 PAID
$this->assertNotNull($order->payment);
$this->assertEquals(PaymentStatusEnum::PAID, $order->payment->payment_status, '전액 마일리지 결제의 결제 레코드는 PAID 여야 합니다.');
}
public function test_create_from_temp_order_full_mileage_payment_fires_payment_complete_hooks(): void
{
$user = User::factory()->create();
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => 50000,
'remaining_amount' => 50000,
'balance_after' => 50000,
]);
$tempOrder = TempOrderFactory::new()
->forUser($user)
->withUsePoints(50000)
->withCalculationResult([
'summary' => ['final_amount' => 0],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
$this->mockCalculationService($this->makeCalculationResult(0, [
'subtotal' => 50000,
'total_shipping' => 0,
'taxable_amount' => 50000,
'payment_amount' => 50000,
'points_used' => 50000,
]));
$afterConfirmFired = false;
$paymentCompleteFired = false;
HookManager::addAction('sirsoft-ecommerce.order.after_confirm', function ($order) use (&$afterConfirmFired) {
$afterConfirmFired = true;
});
HookManager::addAction('sirsoft-ecommerce.order.after_payment_complete', function ($order) use (&$paymentCompleteFired) {
$paymentCompleteFired = true;
});
$this->service->createFromTempOrder(
$tempOrder,
['name' => 'Hook', 'phone' => '010-0000-0000', 'email' => 'hook@test.com'],
['recipient_name' => 'Hook', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
0
);
$this->assertTrue($paymentCompleteFired, '전액 마일리지 결제 시 결제완료 훅이 발화해야 합니다.');
$this->assertTrue($afterConfirmFired, '전액 마일리지 결제 시 주문확인 훅이 발화해야 합니다.');
}
// ===== 마일리지 차감 시점 (mileage.deduction_timing) — 후속 결함 =====
/**
* 부분 마일리지 사용 PG 주문 TempOrder + 잔액 시드를 구성합니다.
*
* @param User $user 사용자
* @param int $usePoints 사용 마일리지
* @param int $finalAmount 차감 후 결제액
*/
protected function makePartialMileageTempOrder(User $user, int $usePoints, int $finalAmount): TempOrder
{
MileageTransaction::create([
'user_id' => $user->id,
'currency' => 'KRW',
'type' => 'purchase_earn',
'amount' => $usePoints,
'remaining_amount' => $usePoints,
'balance_after' => $usePoints,
]);
return TempOrderFactory::new()
->forUser($user)
->withUsePoints($usePoints)
->withCalculationResult([
'summary' => ['final_amount' => $finalAmount],
'items' => [],
'promotions' => [
'product_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
'order_promotions' => ['coupons' => [], 'discount_codes' => [], 'events' => []],
],
'validation_errors' => [],
])
->create();
}
public function test_payment_complete_timing_does_not_deduct_mileage_at_order_creation(): void
{
$user = User::factory()->create();
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
// 기본(payment_complete) 타이밍 — 실제 설정값(defaults.json) 사용
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
'payment_amount' => 55000, 'points_used' => 2000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
53000
);
$order->refresh();
// 결제완료 전이므로 마일리지가 차감되지 않아야 한다 (선차감 결함 방지)
$this->assertFalse((bool) $order->is_mileage_deducted, '결제완료 전(payment_complete 타이밍)에는 마일리지가 차감되지 않아야 합니다.');
$this->assertEquals(0, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '주문 사용 거래가 생성되지 않아야 합니다.');
// 잔액 lot 은 그대로 남아있어야 함
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '결제 전이므로 적립 lot 잔여가 보존되어야 합니다.');
}
public function test_complete_payment_deducts_mileage_for_payment_complete_timing(): void
{
$user = User::factory()->create();
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
'payment_amount' => 55000, 'points_used' => 2000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
53000
);
// 결제완료 처리 → 이 시점에 마일리지 차감
$this->service->completePayment($order->fresh(), [], 53000);
$order->refresh();
$this->assertTrue((bool) $order->is_mileage_deducted, '결제완료 시 마일리지 차감 플래그가 기록되어야 합니다.');
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '결제완료 시점에 주문 사용 거래가 1건 생성되어야 합니다.');
$this->assertEquals(0, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '사용분만큼 적립 lot 잔여가 소진되어야 합니다.');
}
public function test_order_placed_timing_deducts_mileage_at_order_creation(): void
{
$user = User::factory()->create();
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
// 카드 결제수단의 마일리지 차감 시점을 order_placed 로 설정 (testing 격리 경로에 저장됨)
$this->setCardMileageTiming('order_placed');
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
'payment_amount' => 55000, 'points_used' => 2000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
53000
);
$order->refresh();
$this->assertTrue((bool) $order->is_mileage_deducted, 'order_placed 타이밍은 주문 생성 시 차감되어야 합니다.');
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_use')->count(), '주문 생성 시점에 사용 거래가 생성되어야 합니다.');
}
public function test_fail_payment_restores_deducted_mileage(): void
{
$user = User::factory()->create();
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
$this->setCardMileageTiming('order_placed');
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
'payment_amount' => 55000, 'points_used' => 2000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
53000
);
$order->refresh();
$this->assertTrue((bool) $order->is_mileage_deducted, '사전 조건: order_placed 차감이 일어나야 합니다.');
// 결제 실패 → 차감된 마일리지가 복원되어야 한다
$result = $this->service->failPayment($order, 'USER_CANCEL', '결제창 닫음');
$this->assertEquals(OrderStatusEnum::CANCELLED, $result->order_status);
$this->assertFalse((bool) $result->is_mileage_deducted, '복원 후 차감 플래그가 해제되어야 합니다.');
$this->assertEquals(1, MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->count(), '결제 실패 취소 시 복원 거래가 1건 생성되어야 합니다.');
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->sum('amount'), '복원액은 사용 마일리지와 같아야 합니다.');
}
public function test_fail_payment_does_not_create_phantom_mileage_when_not_deducted(): void
{
$user = User::factory()->create();
$tempOrder = $this->makePartialMileageTempOrder($user, 2000, 53000);
// 기본(payment_complete) — 생성 시 차감 안 됨
$this->mockCalculationService($this->makeCalculationResult(53000, [
'subtotal' => 55000, 'total_shipping' => 0, 'taxable_amount' => 53000,
'payment_amount' => 55000, 'points_used' => 2000,
]));
$order = $this->service->createFromTempOrder(
$tempOrder,
['name' => 'PG', 'phone' => '010-0000-0000', 'email' => 'pg@test.com'],
['recipient_name' => 'PG', 'recipient_phone' => '010-0000-0000', 'zipcode' => '00000', 'address' => 'T', 'address_detail' => 'T'],
'card',
53000
);
$order->refresh();
$this->assertFalse((bool) $order->is_mileage_deducted, '사전 조건: 결제 전이라 차감되지 않아야 합니다.');
// 결제 실패 → 차감된 적 없으므로 복원(유령 적립)이 발생하면 안 된다
$this->service->failPayment($order, 'USER_CANCEL', '결제창 닫음');
$this->assertEquals(0, MileageTransaction::where('user_id', $user->id)->where('type', 'order_cancel_restore')->count(), '차감되지 않은 주문은 복원 거래를 생성하지 않아야 합니다 (유령 적립 방지).');
// 잔액 lot 은 차감/복원 모두 없이 그대로
$this->assertEquals(2000, (int) MileageTransaction::where('user_id', $user->id)->where('type', 'purchase_earn')->sum('remaining_amount'), '잔액이 변동 없이 보존되어야 합니다.');
}
public function test_order_status_enum_list_hidden_values_contains_only_pending_order(): void
{
$this->assertEquals(['pending_order'], OrderStatusEnum::listHiddenValues());
}
}