Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/PluginExtensibilityTest.php
T
2026-04-20 20:37:49 +09:00

1148 lines
45 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\User;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderOptionFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderPaymentFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\OrderShippingFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CalculationInput;
use Modules\Sirsoft\Ecommerce\DTO\CalculationItem;
use Modules\Sirsoft\Ecommerce\DTO\CancellationAdjustment;
use Modules\Sirsoft\Ecommerce\DTO\ShippingAddress;
use Modules\Sirsoft\Ecommerce\Enums\ChargePolicyEnum;
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\PaymentStatusEnum;
use Modules\Sirsoft\Ecommerce\Models\Coupon;
use Modules\Sirsoft\Ecommerce\Models\CouponIssue;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\OrderPayment;
use Modules\Sirsoft\Ecommerce\Models\OrderShipping;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicy;
use Modules\Sirsoft\Ecommerce\Models\ShippingPolicyCountrySetting;
use Modules\Sirsoft\Ecommerce\Services\OrderAdjustmentService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 플러그인 확장성 테스트
*
* 3개의 확장 훅이 올바르게 동작하는지 검증합니다:
* - sirsoft-ecommerce.shipping.calculate_fee (배송정책 플러그인)
* - sirsoft-ecommerce.calculation.filter_promotions_snapshot (프로모션 스냅샷 저장)
* - sirsoft-ecommerce.adjustment.filter_restore_promotions (프로모션 스냅샷 복원)
*/
class PluginExtensibilityTest extends ModuleTestCase
{
protected OrderCalculationService $calculationService;
protected OrderAdjustmentService $adjustmentService;
protected function setUp(): void
{
parent::setUp();
$this->setupTestCurrencySettings();
$this->calculationService = app(OrderCalculationService::class);
$this->adjustmentService = app(OrderAdjustmentService::class);
}
protected function tearDown(): void
{
// 테스트 훅 정리
HookManager::resetAll();
$settingsFile = storage_path('app/modules/sirsoft-ecommerce/settings/language_currency.json');
if (file_exists($settingsFile)) {
unlink($settingsFile);
}
parent::tearDown();
}
/**
* 테스트용 통화 설정을 저장합니다.
*/
protected function setupTestCurrencySettings(): void
{
$settingsPath = storage_path('app/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
$settings = [
'default_language' => 'ko',
'default_currency' => 'KRW',
'currencies' => [
[
'code' => 'KRW',
'name' => ['ko' => 'KRW (원)', 'en' => 'KRW (Won)'],
'exchange_rate' => null,
'rounding_unit' => '1',
'rounding_method' => 'floor',
'is_default' => true,
],
],
];
file_put_contents(
$settingsPath.'/language_currency.json',
json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
// ========================================
// 헬퍼 메서드
// ========================================
/**
* 테스트용 상품과 옵션을 생성합니다.
*
* @param int $price 상품 판매가
* @param int $priceAdjustment 옵션 추가금액
* @param ShippingPolicy|null $shippingPolicy 배송정책
* @param float|null $weight 옵션 중량 (kg)
* @return array [Product, ProductOption]
*/
protected function createProductWithOption(
int $price = 50000,
int $priceAdjustment = 0,
?ShippingPolicy $shippingPolicy = null,
?float $weight = null,
): array {
$attrs = [
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
];
if ($shippingPolicy) {
$attrs['shipping_policy_id'] = $shippingPolicy->id;
}
$product = ProductFactory::new()->create($attrs);
$optionAttrs = [
'price_adjustment' => $priceAdjustment,
'stock_quantity' => 100,
'is_default' => true,
];
if ($weight !== null) {
$optionAttrs['weight'] = $weight;
}
$option = ProductOptionFactory::new()->forProduct($product)->create($optionAttrs);
return [$product, $option];
}
/**
* 테스트용 배송정책을 생성합니다.
*
* @param ChargePolicyEnum $chargePolicy 배송비 부과정책
* @param int $baseFee 기본 배송비
* @param int|null $freeThreshold 무료배송 기준금액
* @param array|null $ranges 구간 설정
* @return ShippingPolicy
*/
protected function createShippingPolicy(
ChargePolicyEnum $chargePolicy = ChargePolicyEnum::FREE,
int $baseFee = 0,
?int $freeThreshold = null,
?array $ranges = null,
): ShippingPolicy {
$policy = ShippingPolicy::create([
'name' => ['ko' => '테스트 배송정책', 'en' => 'Test Shipping Policy'],
'is_default' => false,
'is_active' => true,
]);
$policy->countrySettings()->create([
'country_code' => 'KR',
'shipping_method' => 'parcel',
'currency_code' => 'KRW',
'charge_policy' => $chargePolicy,
'base_fee' => $baseFee,
'free_threshold' => $freeThreshold,
'ranges' => $ranges,
'extra_fee_enabled' => false,
'extra_fee_settings' => null,
'extra_fee_multiply' => false,
'is_active' => true,
]);
return $policy->load('countrySettings');
}
/**
* 테스트용 쿠폰 발급 내역을 생성합니다.
*
* @param CouponTargetType $targetType 적용 대상 타입
* @param CouponDiscountType $discountType 할인 타입
* @param float $discountValue 할인 값
* @param CouponTargetScope $targetScope 적용 범위
* @param float $minOrderAmount 최소 주문금액
* @return CouponIssue
*/
protected function createCouponWithIssue(
CouponTargetType $targetType = CouponTargetType::PRODUCT_AMOUNT,
CouponDiscountType $discountType = CouponDiscountType::FIXED,
float $discountValue = 1000,
CouponTargetScope $targetScope = CouponTargetScope::ALL,
float $minOrderAmount = 0,
): CouponIssue {
$coupon = Coupon::create([
'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'],
'description' => ['ko' => '테스트용 쿠폰', 'en' => 'Test coupon'],
'target_type' => $targetType,
'discount_type' => $discountType,
'discount_value' => $discountValue,
'discount_max_amount' => null,
'min_order_amount' => $minOrderAmount,
'target_scope' => $targetScope,
'is_combinable' => true,
'valid_from' => now()->subDay(),
'valid_to' => now()->addDays(30),
]);
$user = User::factory()->create();
return CouponIssue::create([
'coupon_id' => $coupon->id,
'user_id' => $user->id,
'coupon_code' => 'TEST'.uniqid(),
'status' => CouponIssueRecordStatus::AVAILABLE,
'issued_at' => now(),
'expired_at' => now()->addDays(30),
]);
}
/**
* OrderCalculationService로 계산 후 주문 레코드를 생성합니다.
*
* @param CalculationInput $input 계산 입력
* @return Order 생성된 주문
*/
protected function createOrderFromCalculation(CalculationInput $input): Order
{
$result = $this->calculationService->calculate($input);
$user = User::factory()->create();
// 프로모션 스냅샷 (OrderProcessingService::buildPromotionsAppliedSnapshot 흐름 재현)
$promotionsSnapshot = $result->promotions->toArray();
// filter_promotions_snapshot 훅 적용 (프로덕션과 동일 흐름)
$promotionsSnapshot = HookManager::applyFilters(
'sirsoft-ecommerce.calculation.filter_promotions_snapshot',
$promotionsSnapshot,
$result
);
$promotionsSnapshot = array_merge(
$promotionsSnapshot,
[
'coupon_issue_ids' => $input->couponIssueIds,
'item_coupons' => $input->itemCoupons,
'discount_code' => $input->discountCode,
]
);
// 배송정책 스냅샷
$shippingPolicySnapshot = [];
if ($input->shippingAddress) {
$shippingPolicySnapshot['address'] = $input->shippingAddress->toArray();
}
foreach ($result->items as $item) {
if ($item->appliedShippingPolicy) {
$shippingPolicySnapshot[$item->productOptionId] = $item->appliedShippingPolicy->toArray();
}
}
$order = Order::factory()->create([
'user_id' => $user->id,
'order_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'subtotal_amount' => $result->summary->subtotal,
'total_discount_amount' => $result->summary->totalDiscount,
'total_product_coupon_discount_amount' => $result->summary->productCouponDiscount,
'total_order_coupon_discount_amount' => $result->summary->orderCouponDiscount,
'total_coupon_discount_amount' => $result->summary->productCouponDiscount + $result->summary->orderCouponDiscount,
'total_code_discount_amount' => $result->summary->codeDiscount,
'base_shipping_amount' => $result->summary->baseShippingTotal,
'extra_shipping_amount' => $result->summary->extraShippingTotal,
'shipping_discount_amount' => $result->summary->shippingDiscount,
'total_shipping_amount' => $result->summary->totalShipping,
'total_amount' => $result->summary->paymentAmount,
'total_paid_amount' => $result->summary->finalAmount,
'total_due_amount' => 0,
'total_points_used_amount' => $result->summary->pointsUsed,
'total_tax_amount' => $result->summary->taxableAmount,
'total_tax_free_amount' => $result->summary->taxFreeAmount,
'total_cancelled_amount' => 0,
'total_refunded_amount' => 0,
'cancellation_count' => 0,
'currency' => 'KRW',
'promotions_applied_snapshot' => $promotionsSnapshot,
'shipping_policy_applied_snapshot' => $shippingPolicySnapshot,
]);
// OrderOption 생성
foreach ($result->items as $item) {
$product = Product::find($item->productId);
$productOption = ProductOption::find($item->productOptionId);
$createData = [
'product_id' => $item->productId,
'product_option_id' => $item->productOptionId,
'option_status' => OrderStatusEnum::PAYMENT_COMPLETE,
'quantity' => $item->quantity,
'unit_price' => $item->unitPrice,
'subtotal_price' => $item->subtotal,
'subtotal_discount_amount' => $item->productCouponDiscountAmount + $item->codeDiscountAmount + $item->orderCouponDiscountShare,
'coupon_discount_amount' => $item->productCouponDiscountAmount + $item->orderCouponDiscountShare,
'code_discount_amount' => $item->codeDiscountAmount,
'subtotal_paid_amount' => $item->finalAmount,
'subtotal_tax_amount' => $item->taxableAmount,
'subtotal_tax_free_amount' => $item->taxFreeAmount,
'subtotal_points_used_amount' => $item->pointsUsedShare,
];
if ($product) {
$createData['product_snapshot'] = $product->toSnapshotArray();
}
if ($productOption) {
$createData['option_snapshot'] = $productOption->toSnapshotArray();
}
OrderOption::factory()->forOrder($order)->create($createData);
}
// OrderShipping 생성
OrderShipping::factory()->forOrder($order)->create([
'shipping_status' => 'pending',
'base_shipping_amount' => $result->summary->baseShippingTotal,
'extra_shipping_amount' => $result->summary->extraShippingTotal,
'total_shipping_amount' => $result->summary->totalShipping,
'shipping_discount_amount' => $result->summary->shippingDiscount,
]);
// OrderPayment 생성
OrderPayment::factory()->forOrder($order)->create([
'payment_status' => PaymentStatusEnum::PAID,
'paid_amount_local' => $result->summary->finalAmount,
'paid_amount_base' => $result->summary->finalAmount,
'paid_at' => now(),
]);
// 쿠폰 사용 처리
foreach ($input->couponIssueIds as $couponIssueId) {
CouponIssue::where('id', $couponIssueId)->update([
'status' => CouponIssueRecordStatus::USED,
'used_at' => now(),
'order_id' => $order->id,
]);
}
return $order->load(['options', 'shippings', 'payment']);
}
// ================================================================
// A. 배송정책 플러그인 테스트 (TC-S1 ~ TC-S6)
// ================================================================
/**
* TC-S1: 플러그인 배송비 훅 — 기본 동작
*
* 플러그인이 shipping.calculate_fee 훅에 리스너를 등록하면
* 내장 match 로직 대신 플러그인의 계산 결과가 사용된다.
*/
public function test_s1_plugin_shipping_fee_hook_overrides_builtin_calculation(): void
{
// Given: FIXED 3,000원 배송정책
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(10000, 0, $shippingPolicy);
// 플러그인 훅: 항상 8,000원 반환
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee, $countrySetting, $group) {
return 8000;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2
),
]
);
// When
$result = $this->calculationService->calculate($input);
// Then: 플러그인 배송비 8,000원 적용 (FIXED 3,000원 아님)
$this->assertEquals(8000, $result->summary->baseShippingTotal);
}
/**
* TC-S2: 플러그인 미등록 시 기존 로직 유지
*
* shipping.calculate_fee 훅에 리스너가 없으면
* 기존 내장 match 로직이 100% 동일하게 동작한다.
*/
public function test_s2_no_plugin_uses_builtin_calculation(): void
{
// Given: CONDITIONAL_FREE 정책 (기준금액 50,000원, 기본 3,000원)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::CONDITIONAL_FREE, 3000, 50000);
// 상품 30,000원 × 1개 (< threshold)
[$productA, $optionA] = $this->createProductWithOption(30000, 0, $shippingPolicy);
// 상품 30,000원 × 2개 (= 60,000원 ≥ threshold)
[$productB, $optionB] = $this->createProductWithOption(30000, 0, $shippingPolicy);
// 훅 미등록 상태
// When: 30,000원 (미달)
$resultUnder = $this->calculationService->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 1),
]
));
// When: 60,000원 (이상)
$resultOver = $this->calculationService->calculate(new CalculationInput(
items: [
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 2),
]
));
// Then
$this->assertEquals(3000, $resultUnder->summary->baseShippingTotal); // threshold 미달 → base_fee
$this->assertEquals(0, $resultOver->summary->baseShippingTotal); // threshold 이상 → 무료
}
/**
* TC-S3: 스냅샷 모드에서 훅 미호출 + 스냅샷 금액 사용
*
* 환불 재계산(snapshot_mode=true) 시 플러그인 훅이 호출되지 않고
* 스냅샷에 저장된 배송비가 그대로 사용된다.
*/
public function test_s3_snapshot_mode_uses_snapshot_fee_not_hook(): void
{
// Given: FIXED 3,000원 배송정책
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(50000, 0, $shippingPolicy);
// 플러그인 훅: 8,000원 + 호출 여부 추적
$hookCalled = false;
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee, $countrySetting, $group) use (&$hookCalled) {
$hookCalled = true;
return 8000;
}
);
// 주문 생성 (플러그인 배송비 8,000원 적용)
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$order = $this->createOrderFromCalculation($input);
// 배송비 8,000원 확인
$this->assertEquals(8000, $order->base_shipping_amount);
// 훅 호출 플래그 리셋
$hookCalled = false;
// When: 전체취소 환불 재계산 (snapshot_mode=true)
$cancellation = CancellationAdjustment::fromArray(
$order->options->map(fn ($opt) => [
'order_option_id' => $opt->id,
'cancel_quantity' => $opt->quantity,
])->toArray()
);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 스냅샷 금액 사용, 훅 미호출
$this->assertFalse($hookCalled, '스냅샷 모드에서 shipping.calculate_fee 훅이 호출되지 않아야 함');
}
/**
* TC-S4: 스냅샷 모드 + 플러그인 OFF (훅 미등록)
*
* 환불 재계산 시 플러그인이 비활성(훅 미등록)이어도
* 스냅샷에 저장된 배송비가 정확하게 사용된다.
*/
public function test_s4_snapshot_mode_plugin_off_uses_snapshot_fee(): void
{
// Given: FIXED 3,000원 배송정책
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(50000, 0, $shippingPolicy);
// 플러그인 훅 ON: 8,000원 배송비
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 8000;
}
);
// 주문 생성 (플러그인 배송비 8,000원 적용)
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$order = $this->createOrderFromCalculation($input);
$this->assertEquals(8000, $order->base_shipping_amount);
// 플러그인 OFF (훅 제거)
HookManager::resetAll();
// When: 전체취소 환불 재계산 (플러그인 OFF 상태)
$cancellation = CancellationAdjustment::fromArray(
$order->options->map(fn ($opt) => [
'order_option_id' => $opt->id,
'cancel_quantity' => $opt->quantity,
])->toArray()
);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 스냅샷의 8,000원 사용 (match default의 3,000원이 아님)
// 전체취소이므로 환불액에 배송비 8,000원 포함
$this->assertEquals(
$order->total_paid_amount,
$adjustmentResult->refundAmount,
'플러그인 OFF 상태에서도 스냅샷 기반 환불액이 정확해야 함'
);
}
/**
* TC-S5: 플러그인 훅 우선순위 (여러 플러그인 경쟁)
*
* Filter 훅이므로 마지막 필터의 반환값이 최종 적용된다.
*/
public function test_s5_multiple_plugins_last_filter_wins(): void
{
// Given: FIXED 3,000원 배송정책
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(10000, 0, $shippingPolicy);
// 플러그인 A: priority 10, 5,000원
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 5000;
},
10
);
// 플러그인 B: priority 20, 7,000원
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 7000;
},
20
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
);
// When
$result = $this->calculationService->calculate($input);
// Then: 마지막 필터(priority 20)의 7,000원 적용
$this->assertEquals(7000, $result->summary->baseShippingTotal);
}
/**
* TC-S6: 정상 주문(플러그인 배송비) → 부분취소 → 환불 재계산 전체 흐름
*
* 주문 생성 시 플러그인이 15,000원 배송비 → 부분취소 후
* 재계산 시 스냅샷의 15,000원 기준으로 환불액 산출.
*/
public function test_s6_full_flow_order_with_plugin_fee_then_partial_cancel(): void
{
// Given: FIXED 3,000원 배송정책 (플러그인이 오버라이드)
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$productA, $optionA] = $this->createProductWithOption(50000, 0, $shippingPolicy);
[$productB, $optionB] = $this->createProductWithOption(30000, 0, $shippingPolicy);
// 플러그인 훅: 15,000원 배송비
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 15000;
}
);
// 주문 생성: 상품A 50,000 × 2 + 상품B 30,000 × 1 + 배송비 15,000
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 2),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
]
);
$order = $this->createOrderFromCalculation($input);
// 주문 금액 검증
$this->assertEquals(130000, $order->subtotal_amount); // 100,000 + 30,000
$this->assertEquals(15000, $order->base_shipping_amount);
// 플러그인 OFF
HookManager::resetAll();
// When: 상품A 1개 부분취소
$optionARecord = $order->options->firstWhere('product_option_id', $optionA->id);
$cancellation = CancellationAdjustment::fromArray([
[
'order_option_id' => $optionARecord->id,
'cancel_quantity' => 1,
],
]);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 환불액은 상품A 1개(50,000원) + 배송비 차이
// 배송비는 스냅샷 기준 15,000원으로 재계산 (플러그인 OFF 무관)
$this->assertGreaterThan(0, $adjustmentResult->refundAmount);
// 상품 환불액은 최소 50,000원 (배송비 차이에 따라 추가)
$this->assertGreaterThanOrEqual(50000, $adjustmentResult->refundAmount);
}
// ================================================================
// B. 할인정책 플러그인 테스트 (TC-D1 ~ TC-D7)
// ================================================================
/**
* TC-D1: after_product_discount 훅으로 추가 할인 주입
*
* 플러그인이 상품 할인 후 추가 3,000원 할인을 적용한다.
*/
public function test_d1_plugin_adds_discount_via_after_product_discount_hook(): void
{
// Given: 50,000원 상품 + 10% 쿠폰 (5,000원 할인)
[$product, $option] = $this->createProductWithOption(50000);
$couponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
targetScope: CouponTargetScope::ALL,
);
// 플러그인 훅: after_product_discount에서 추가 3,000원 할인
HookManager::addFilter(
'sirsoft-ecommerce.calculation.after_product_discount',
function ($discountedItems) {
foreach ($discountedItems as &$item) {
$item['coupon_discount'] = ($item['coupon_discount'] ?? 0) + 3000;
$item['discounted_subtotal'] = ($item['discounted_subtotal'] ?? $item['subtotal']) - 3000;
}
return $discountedItems;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$couponIssue->id],
);
// When
$result = $this->calculationService->calculate($input);
// Then: 50,000 - 5,000(쿠폰) - 3,000(플러그인) = 42,000원
$this->assertEquals(42000, $result->summary->paymentAmount - $result->summary->totalShipping);
}
/**
* TC-D2: before_product_discount 훅으로 자동 쿠폰 주입
*
* 플러그인이 상품 할인 전에 자동 쿠폰을 주입한다.
*/
public function test_d2_plugin_injects_auto_coupon_via_before_product_discount(): void
{
// Given: 100,000원 상품, 수동 쿠폰 없음
[$product, $option] = $this->createProductWithOption(100000);
// 자동 쿠폰 생성
$autoCouponIssue = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10, // 10% = 10,000원
targetScope: CouponTargetScope::ALL,
);
// 플러그인 훅: before_product_discount에서 자동 쿠폰을 쿠폰 목록에 추가
HookManager::addFilter(
'sirsoft-ecommerce.calculation.before_product_discount',
function ($productCoupons) use ($autoCouponIssue) {
$productCoupons[] = $autoCouponIssue;
return $productCoupons;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
// 수동 쿠폰 없음
);
// When
$result = $this->calculationService->calculate($input);
// Then: 플러그인이 주입한 쿠폰으로 10% 할인 적용
$this->assertEquals(10000, $result->summary->productCouponDiscount);
$this->assertEquals(90000, $result->summary->paymentAmount - $result->summary->totalShipping);
}
/**
* TC-D3: 프로모션 스냅샷 저장 — 플러그인 데이터 포함
*
* filter_promotions_snapshot 훅으로 플러그인이 자체 할인 데이터를
* 스냅샷에 추가할 수 있다.
*/
public function test_d3_plugin_data_included_in_promotions_snapshot(): void
{
// Given
[$product, $option] = $this->createProductWithOption(50000);
// 플러그인 훅: 스냅샷에 referral_discount 데이터 추가
HookManager::addFilter(
'sirsoft-ecommerce.calculation.filter_promotions_snapshot',
function ($snapshot, $result) {
$snapshot['referral_discount'] = [
'amount' => 3000,
'source' => 'ref_code_ABC',
];
return $snapshot;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
);
// 주문 생성 (buildPromotionsAppliedSnapshot 훅 실행)
$order = $this->createOrderFromCalculation($input);
// Then: 스냅샷에 플러그인 데이터 포함 확인
$snapshot = $order->promotions_applied_snapshot;
$this->assertArrayHasKey('referral_discount', $snapshot);
$this->assertEquals(3000, $snapshot['referral_discount']['amount']);
$this->assertEquals('ref_code_ABC', $snapshot['referral_discount']['source']);
}
/**
* TC-D4: 프로모션 스냅샷 복원 — 플러그인 ON
*
* filter_restore_promotions 훅으로 플러그인이 스냅샷에서
* 자체 할인 데이터를 해석/복원할 수 있다.
*/
public function test_d4_plugin_restores_data_from_promotions_snapshot(): void
{
// Given: 주문 생성
[$product, $option] = $this->createProductWithOption(50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$order = $this->createOrderFromCalculation($input);
// 스냅샷에 플러그인 데이터 수동 추가 (주문 시점 시뮬레이션)
$snapshot = $order->promotions_applied_snapshot ?? [];
$snapshot['referral_discount'] = ['amount' => 3000, 'source' => 'ref_code_ABC'];
$order->update(['promotions_applied_snapshot' => $snapshot]);
$order->refresh();
// 플러그인 훅: 복원 시 referral_discount 키 확인 가능
$pluginDataRestored = false;
HookManager::addFilter(
'sirsoft-ecommerce.adjustment.filter_restore_promotions',
function ($promoSnapshot, $orderModel) use (&$pluginDataRestored) {
if (isset($promoSnapshot['referral_discount'])) {
$pluginDataRestored = true;
}
return $promoSnapshot;
}
);
// When: 환불 재계산
$cancellation = CancellationAdjustment::fromArray(
$order->options->map(fn ($opt) => [
'order_option_id' => $opt->id,
'cancel_quantity' => $opt->quantity,
])->toArray()
);
$this->adjustmentService->calculate($order, $cancellation);
// Then: 플러그인이 스냅샷 데이터를 수신함
$this->assertTrue($pluginDataRestored, '플러그인이 스냅샷에서 referral_discount 데이터를 수신해야 함');
}
/**
* TC-D5: 프로모션 스냅샷 복원 — 플러그인 OFF (핵심 안전 테스트)
*
* 플러그인 비활성 시 filter_restore_promotions 훅 미등록 →
* $promoSnapshot이 변경 없이 그대로 사용된다.
*/
public function test_d5_plugin_off_snapshot_restore_safe(): void
{
// Given: 주문 생성
[$product, $option] = $this->createProductWithOption(50000);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]
);
$order = $this->createOrderFromCalculation($input);
// 스냅샷에 플러그인 데이터 포함
$snapshot = $order->promotions_applied_snapshot ?? [];
$snapshot['referral_discount'] = ['amount' => 3000];
$order->update(['promotions_applied_snapshot' => $snapshot]);
$order->refresh();
// 플러그인 OFF (훅 미등록)
HookManager::resetAll();
// When: 환불 재계산 (플러그인 OFF)
$cancellation = CancellationAdjustment::fromArray(
$order->options->map(fn ($opt) => [
'order_option_id' => $opt->id,
'cancel_quantity' => $opt->quantity,
])->toArray()
);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 전체 취소 시 원 결제금액 전액 환불 (snapshot_mode 기반)
$this->assertEquals(
$order->total_paid_amount,
$adjustmentResult->refundAmount,
'플러그인 OFF 상태에서도 전액 환불이 정확해야 함'
);
}
/**
* TC-D6: 플러그인 할인 + 내장 쿠폰 동시 적용
*
* 유입할인(플러그인) + 상품쿠폰(내장) + 주문쿠폰(내장)이 복합으로 동작한다.
*/
public function test_d6_plugin_discount_plus_builtin_coupons(): void
{
// Given: 100,000원 상품
[$product, $option] = $this->createProductWithOption(100000);
// 상품 쿠폰 10% (10,000원)
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
// 주문 쿠폰 5,000원
$orderCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::ORDER_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 5000,
);
// 플러그인 훅: after_product_discount에서 추가 3,000원 할인
HookManager::addFilter(
'sirsoft-ecommerce.calculation.after_product_discount',
function ($discountedItems) {
foreach ($discountedItems as &$item) {
$item['coupon_discount'] = ($item['coupon_discount'] ?? 0) + 3000;
$item['discounted_subtotal'] = ($item['discounted_subtotal'] ?? $item['subtotal']) - 3000;
}
return $discountedItems;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$productCoupon->id, $orderCoupon->id],
);
// When
$result = $this->calculationService->calculate($input);
// Then: 100,000 - 10,000(쿠폰) - 3,000(플러그인) - 5,000(주문쿠폰) = 82,000원
$paymentWithoutShipping = $result->summary->paymentAmount - $result->summary->totalShipping;
$this->assertEquals(82000, $paymentWithoutShipping);
}
/**
* TC-D7: 전체 흐름 — 주문(플러그인 할인) → 부분취소 → 환불
*
* 내장 쿠폰 + 플러그인 할인으로 주문 → 부분취소 → snapshot_mode 환불 재계산.
* 플러그인 ON/OFF 무관하게 환불액이 정확해야 한다.
*/
public function test_d7_full_flow_plugin_discount_then_partial_cancel(): void
{
// Given: 50,000원 × 2개 + 30,000원 × 1개
[$productA, $optionA] = $this->createProductWithOption(50000);
[$productB, $optionB] = $this->createProductWithOption(30000);
// 상품 쿠폰 10,000원
$coupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::FIXED,
discountValue: 10000,
);
// 플러그인 훅: after_product_discount에서 추가 5,000원 할인
HookManager::addFilter(
'sirsoft-ecommerce.calculation.after_product_discount',
function ($discountedItems) {
foreach ($discountedItems as &$item) {
$item['coupon_discount'] = ($item['coupon_discount'] ?? 0) + 5000;
$item['discounted_subtotal'] = ($item['discounted_subtotal'] ?? $item['subtotal']) - 5000;
}
return $discountedItems;
}
);
// 주문 생성
$input = new CalculationInput(
items: [
new CalculationItem(productId: $productA->id, productOptionId: $optionA->id, quantity: 2),
new CalculationItem(productId: $productB->id, productOptionId: $optionB->id, quantity: 1),
],
couponIssueIds: [$coupon->id],
);
$order = $this->createOrderFromCalculation($input);
$originalPaidAmount = $order->total_paid_amount;
// 플러그인 OFF
HookManager::resetAll();
// When: 상품A 1개 부분취소
$optionARecord = $order->options->firstWhere('product_option_id', $optionA->id);
$cancellation = CancellationAdjustment::fromArray([
[
'order_option_id' => $optionARecord->id,
'cancel_quantity' => 1,
],
]);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 환불액이 양수이고 원 결제금액 이하
$this->assertGreaterThan(0, $adjustmentResult->refundAmount);
$this->assertLessThanOrEqual($originalPaidAmount, $adjustmentResult->refundAmount);
}
// ================================================================
// C. 복합 시나리오 테스트 (TC-C1 ~ TC-C3)
// ================================================================
/**
* TC-C1: 플러그인 배송비 + 플러그인 할인 + 내장 쿠폰 동시
*
* 모든 확장 포인트가 동시에 활성인 경우.
*/
public function test_c1_all_extension_points_active_simultaneously(): void
{
// Given: 100,000원 상품
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(100000, 0, $shippingPolicy);
// 상품 쿠폰 10% (10,000원)
$productCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::PRODUCT_AMOUNT,
discountType: CouponDiscountType::RATE,
discountValue: 10,
);
// 배송비 쿠폰 2,000원
$shippingCoupon = $this->createCouponWithIssue(
targetType: CouponTargetType::SHIPPING_FEE,
discountType: CouponDiscountType::FIXED,
discountValue: 2000,
);
// 플러그인 배송비: 12,000원
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 12000;
}
);
// 플러그인 할인: after_product_discount에서 추가 3,000원
HookManager::addFilter(
'sirsoft-ecommerce.calculation.after_product_discount',
function ($discountedItems) {
foreach ($discountedItems as &$item) {
$item['coupon_discount'] = ($item['coupon_discount'] ?? 0) + 3000;
$item['discounted_subtotal'] = ($item['discounted_subtotal'] ?? $item['subtotal']) - 3000;
}
return $discountedItems;
}
);
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
],
couponIssueIds: [$productCoupon->id, $shippingCoupon->id],
);
// When
$result = $this->calculationService->calculate($input);
// Then:
// 상품 할인: 10,000(쿠폰) + 3,000(플러그인) = 13,000
$this->assertEquals(13000, $result->summary->totalDiscount);
// 상품 순액: 100,000 - 13,000 = 87,000
$this->assertEquals(87000, $result->summary->subtotal - $result->summary->totalDiscount);
// 배송비: 12,000(플러그인) - 2,000(쿠폰) = 10,000
$this->assertEquals(12000, $result->summary->baseShippingTotal);
$this->assertEquals(2000, $result->summary->shippingDiscount);
// 최종: 87,000 + 12,000(배송비) - 2,000(배송할인) = 97,000
$this->assertEquals(97000, $result->summary->paymentAmount);
}
/**
* TC-C2: 모든 플러그인 OFF 상태에서 환불 재계산
*
* 주문 시 모든 플러그인 ON → 환불 시 모든 플러그인 OFF.
* 스냅샷 기반으로 환불액이 정확해야 한다.
*/
public function test_c2_all_plugins_off_refund_uses_snapshots(): void
{
// Given: 모든 플러그인 ON
$shippingPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$product, $option] = $this->createProductWithOption(100000, 0, $shippingPolicy);
// 플러그인 배송비: 12,000원
HookManager::addFilter(
'sirsoft-ecommerce.shipping.calculate_fee',
function ($currentFee) {
return 12000;
}
);
// 플러그인 할인: 5,000원
HookManager::addFilter(
'sirsoft-ecommerce.calculation.after_product_discount',
function ($discountedItems) {
foreach ($discountedItems as &$item) {
$item['coupon_discount'] = ($item['coupon_discount'] ?? 0) + 5000;
$item['discounted_subtotal'] = ($item['discounted_subtotal'] ?? $item['subtotal']) - 5000;
}
return $discountedItems;
}
);
// 주문 생성
$input = new CalculationInput(
items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 1),
]
);
$order = $this->createOrderFromCalculation($input);
// 원 결제금액: 100,000 - 5,000(플러그인 할인) + 12,000(플러그인 배송비) = 107,000
$this->assertEquals(107000, $order->total_paid_amount);
// 모든 플러그인 OFF
HookManager::resetAll();
// When: 전체취소
$cancellation = CancellationAdjustment::fromArray(
$order->options->map(fn ($opt) => [
'order_option_id' => $opt->id,
'cancel_quantity' => $opt->quantity,
])->toArray()
);
$adjustmentResult = $this->adjustmentService->calculate($order, $cancellation);
// Then: 전체취소 → 원 결제금액 전액 환불
$this->assertEquals(
$order->total_paid_amount,
$adjustmentResult->refundAmount,
'모든 플러그인 OFF 상태에서도 전액 환불이 정확해야 함'
);
}
/**
* TC-C3: 기존 내장 계산 로직 무영향 (회귀 테스트)
*
* 플러그인 훅 미등록 상태에서 기존 배송비 정책들이 모두 정상 동작한다.
*/
public function test_c3_builtin_policies_unaffected_regression(): void
{
// 훅 미등록 상태 확인
HookManager::resetAll();
// FREE 정책
$freePolicy = $this->createShippingPolicy(ChargePolicyEnum::FREE, 0);
[$prodFree, $optFree] = $this->createProductWithOption(10000, 0, $freePolicy);
$result = $this->calculationService->calculate(new CalculationInput(
items: [new CalculationItem(productId: $prodFree->id, productOptionId: $optFree->id, quantity: 1)]
));
$this->assertEquals(0, $result->summary->baseShippingTotal, 'FREE 정책: 0원');
// FIXED 정책
$fixedPolicy = $this->createShippingPolicy(ChargePolicyEnum::FIXED, 3000);
[$prodFixed, $optFixed] = $this->createProductWithOption(10000, 0, $fixedPolicy);
$result = $this->calculationService->calculate(new CalculationInput(
items: [new CalculationItem(productId: $prodFixed->id, productOptionId: $optFixed->id, quantity: 1)]
));
$this->assertEquals(3000, $result->summary->baseShippingTotal, 'FIXED 정책: 3,000원');
// CONDITIONAL_FREE 정책 (threshold 50,000원)
$condPolicy = $this->createShippingPolicy(ChargePolicyEnum::CONDITIONAL_FREE, 3000, 50000);
[$prodCond, $optCond] = $this->createProductWithOption(60000, 0, $condPolicy);
$result = $this->calculationService->calculate(new CalculationInput(
items: [new CalculationItem(productId: $prodCond->id, productOptionId: $optCond->id, quantity: 1)]
));
$this->assertEquals(0, $result->summary->baseShippingTotal, 'CONDITIONAL_FREE: 60,000 ≥ 50,000 → 무료');
// CONDITIONAL_FREE 미달
[$prodCond2, $optCond2] = $this->createProductWithOption(30000, 0, $condPolicy);
$result = $this->calculationService->calculate(new CalculationInput(
items: [new CalculationItem(productId: $prodCond2->id, productOptionId: $optCond2->id, quantity: 1)]
));
$this->assertEquals(3000, $result->summary->baseShippingTotal, 'CONDITIONAL_FREE: 30,000 < 50,000 → 3,000원');
}
}