Files
Gnuboard7/modules/_bundled/sirsoft-ecommerce/tests/Unit/Services/AdditionalOptionsCalculationTest.php
T
2026-07-01 10:30:32 +09:00

413 lines
16 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 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\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOption;
use Modules\Sirsoft\Ecommerce\Models\ProductAdditionalOptionValue;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 추가옵션(유료) 계산 Unit 테스트
*
* OrderCalculationService 가 추가옵션 추가금을 안B 산식으로 가산하는지 검증합니다.
* - subtotal = (unit_price + additional_options_total) × quantity (D6)
* - unit_price 는 원옵션가 유지 (안B)
* - 가격은 value_id 기준 서버 재조회 (클라 가격 신뢰 금지)
* - 정률 적립은 추가옵션 포함, 정액 적립은 무관 (D5)
* - 타상품/비활성 value_id 는 무시
*/
class AdditionalOptionsCalculationTest extends ModuleTestCase
{
protected OrderCalculationService $service;
protected function setUp(): void
{
parent::setUp();
$this->setupTestCurrencySettings();
$this->service = app(OrderCalculationService::class);
}
/**
* 테스트용 통화/마일리지 설정을 저장합니다.
*/
protected function setupTestCurrencySettings(): void
{
$settingsPath = storage_path('framework/testing/modules/sirsoft-ecommerce/settings');
if (! is_dir($settingsPath)) {
mkdir($settingsPath, 0755, true);
}
file_put_contents(
$settingsPath.'/language_currency.json',
json_encode([
'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,
],
],
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
file_put_contents(
$settingsPath.'/mileage.json',
json_encode([
'enabled' => true,
'default_earn_rate' => 1,
'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' => true,
'expiry_days' => 365,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)
);
}
protected function tearDown(): void
{
foreach (['language_currency', 'mileage'] as $name) {
$file = storage_path("framework/testing/modules/sirsoft-ecommerce/settings/{$name}.json");
if (file_exists($file)) {
unlink($file);
}
}
parent::tearDown();
}
/**
* 추가옵션 그룹 + 선택지를 생성합니다.
*
* @param Product $product 상품
* @param int $priceAdjustment 선택지 추가금
* @param bool $isRequired 필수 그룹 여부
* @param bool $isActive 선택지 활성 여부
* @return ProductAdditionalOptionValue 생성된 선택지
*/
protected function createAdditionalOptionValue(
Product $product,
int $priceAdjustment,
bool $isRequired = false,
bool $isActive = true,
bool $allowCustomText = false
): ProductAdditionalOptionValue {
$group = ProductAdditionalOption::create([
'product_id' => $product->id,
'name' => ['ko' => '각인', 'en' => 'Engraving'],
'is_required' => $isRequired,
'sort_order' => 0,
]);
return ProductAdditionalOptionValue::create([
'additional_option_id' => $group->id,
'name' => ['ko' => '각인 추가', 'en' => 'Add engraving'],
'price_adjustment' => $priceAdjustment,
'is_default' => false,
'is_active' => $isActive,
'allow_custom_text' => $allowCustomText,
'sort_order' => 0,
]);
}
/**
* 테스트용 상품/옵션을 생성합니다.
*
* @param int $price 판매가
* @param string $mileageType 적립 유형 (percent/fixed)
* @param float|null $mileageValue 적립 값
* @return array{0: Product, 1: ProductOption}
*/
protected function createProductWithOption(int $price = 10000, ?string $mileageType = null, ?float $mileageValue = null): array
{
$product = ProductFactory::new()->create([
'tax_status' => 'taxable',
'selling_price' => $price,
'list_price' => $price,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'price_adjustment' => 0,
'stock_quantity' => 100,
'is_default' => true,
'mileage_type' => $mileageType,
'mileage_value' => $mileageValue,
]);
return [$product, $option];
}
public function test_subtotal_includes_additional_option_times_quantity(): void
{
// Given: 10,000원 상품 × 3개 + 각인 추가 5,000원
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 3,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id],
],
),
]);
// When
$result = $this->service->calculate($input);
// Then: subtotal = (10000 + 5000) × 3 = 45,000, unit_price 는 원옵션가 10,000 유지 (안B)
$this->assertCount(1, $result->items);
$this->assertEquals(10000, $result->items[0]->unitPrice);
$this->assertEquals(5000, $result->items[0]->additionalOptionsTotal);
$this->assertEquals(45000, $result->items[0]->subtotal);
}
public function test_no_additional_option_keeps_base_subtotal(): void
{
// Given: 추가옵션 미선택
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(productId: $product->id, productOptionId: $option->id, quantity: 2),
]);
$result = $this->service->calculate($input);
$this->assertEquals(10000, $result->items[0]->unitPrice);
$this->assertEquals(0, $result->items[0]->additionalOptionsTotal);
$this->assertEquals(20000, $result->items[0]->subtotal);
}
public function test_price_is_resolved_server_side_ignoring_client_value(): void
{
// Given: 선택지 추가금은 서버 DB 5,000원. 클라가 다른 값을 보내도 무시되어야 함.
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
// 클라가 임의 price 를 끼워넣어도 DTO 는 value_id 만 신뢰
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id, 'price_adjustment' => 999999],
],
),
]);
$result = $this->service->calculate($input);
// 서버 DB 값(5000)으로 계산
$this->assertEquals(5000, $result->items[0]->additionalOptionsTotal);
$this->assertEquals(15000, $result->items[0]->subtotal);
}
public function test_value_from_other_product_is_ignored(): void
{
// Given: 다른 상품의 선택지 value_id 를 선택 → 무시되어 추가금 0
[$product, $option] = $this->createProductWithOption(price: 10000);
[$otherProduct] = $this->createProductWithOption(price: 8000);
$otherValue = $this->createAdditionalOptionValue($otherProduct, priceAdjustment: 7000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $otherValue->additional_option_id, 'value_id' => $otherValue->id],
],
),
]);
$result = $this->service->calculate($input);
$this->assertEquals(0, $result->items[0]->additionalOptionsTotal);
$this->assertEquals(10000, $result->items[0]->subtotal);
}
public function test_inactive_value_is_ignored(): void
{
// Given: 비활성 선택지 → 추가금 0
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000, isActive: false);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id],
],
),
]);
$result = $this->service->calculate($input);
$this->assertEquals(0, $result->items[0]->additionalOptionsTotal);
}
public function test_percent_mileage_includes_additional_option(): void
{
// Given: 정률 1% 적립, 10,000 + 5,000 = 15,000 → 적립 150
[$product, $option] = $this->createProductWithOption(price: 10000, mileageType: 'percent', mileageValue: 1.0);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id],
],
),
]);
$result = $this->service->calculate($input);
// 정률 적립은 추가옵션 포함 subtotal(15000) 기준 → 150
$this->assertEquals(150, $result->items[0]->pointsEarning);
}
public function test_fixed_mileage_excludes_additional_option(): void
{
// Given: 정액 500 적립 × 수량 2 = 1000, 추가옵션과 무관
[$product, $option] = $this->createProductWithOption(price: 10000, mileageType: 'fixed', mileageValue: 500.0);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id],
],
),
]);
$result = $this->service->calculate($input);
// 정액 적립: 500 × 2 = 1000 (추가옵션 무관)
$this->assertEquals(1000, $result->items[0]->pointsEarning);
}
public function test_additional_options_snapshot_is_built(): void
{
// Given
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id],
],
),
]);
$result = $this->service->calculate($input);
$snapshot = $result->items[0]->additionalOptionsSnapshot;
$this->assertIsArray($snapshot);
$this->assertCount(1, $snapshot);
$this->assertEquals($value->id, $snapshot[0]['value_id']);
$this->assertEquals(5000, $snapshot[0]['price_adjustment']);
}
public function test_custom_text_is_frozen_into_snapshot(): void
{
// Given: allow_custom_text 선택지 + 직접입력 텍스트 → 스냅샷에 동결 (E3)
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000, allowCustomText: true);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id, 'custom_text' => '홍길동'],
],
),
]);
$result = $this->service->calculate($input);
$snapshot = $result->items[0]->additionalOptionsSnapshot;
$this->assertSame('홍길동', $snapshot[0]['custom_text']);
// 직접입력 텍스트는 가격에 무관 (E2)
$this->assertEquals(5000, $result->items[0]->additionalOptionsTotal);
}
public function test_custom_text_not_frozen_when_value_disallows(): void
{
// Given: allow_custom_text=false 선택지 — custom_text 전송돼도 스냅샷 미주입 (E4)
[$product, $option] = $this->createProductWithOption(price: 10000);
$value = $this->createAdditionalOptionValue($product, priceAdjustment: 5000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 1,
additionalOptionSelections: [
['additional_option_id' => $value->additional_option_id, 'value_id' => $value->id, 'custom_text' => '무시'],
],
),
]);
$result = $this->service->calculate($input);
$this->assertArrayNotHasKey('custom_text', $result->items[0]->additionalOptionsSnapshot[0]);
}
public function test_snapshot_mode_uses_frozen_additional_total(): void
{
// Given: 환불 재계산(스냅샷 모드) — DB 가 아닌 동결 스냅샷 추가금 사용
[$product, $option] = $this->createProductWithOption(price: 10000);
$input = new CalculationInput(items: [
new CalculationItem(
productId: $product->id,
productOptionId: $option->id,
quantity: 2,
productSnapshot: $product->toSnapshotArray(),
optionSnapshot: $option->toSnapshotArray(),
additionalOptionsSnapshot: [
['additional_option_id' => 999, 'value_id' => 888, 'name' => ['ko' => '각인'], 'price_adjustment' => 3000, 'mc_price_adjustment' => null],
],
),
]);
$result = $this->service->calculate($input);
// 동결 추가금 3000 사용: subtotal = (10000 + 3000) × 2 = 26000
$this->assertEquals(3000, $result->items[0]->additionalOptionsTotal);
$this->assertEquals(26000, $result->items[0]->subtotal);
}
}