setupTestCurrencySettings(); $this->service = app(OrderCalculationService::class); $this->currencyService = app(CurrencyConversionService::class); } /** * 테스트용 통화 설정을 저장합니다. */ protected function setupTestCurrencySettings(): void { $settingsPath = storage_path('framework/testing/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, ], [ 'code' => 'USD', 'name' => ['ko' => 'USD (달러)', 'en' => 'USD (Dollar)'], 'exchange_rate' => 1.35, 'rounding_unit' => '0.01', 'rounding_method' => 'round', 'is_default' => false, ], ], ]; file_put_contents( $settingsPath.'/language_currency.json', json_encode($settings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) ); // g7_module_settings() 는 Config::get('g7_settings.modules.{id}') 기반. // 테스트 환경에서는 모듈 활성화가 안 돼 있어 CoreServiceProvider 가 Config 를 주입하지 않음. \Illuminate\Support\Facades\Config::set( 'g7_settings.modules.sirsoft-ecommerce.language_currency', $settings ); } protected function tearDown(): void { $settingsFile = storage_path('framework/testing/modules/sirsoft-ecommerce/settings/language_currency.json'); if (file_exists($settingsFile)) { unlink($settingsFile); } parent::tearDown(); } // ======================================== // Section 1: SnapshotProduct/SnapshotProductOption DTO 테스트 // ======================================== /** * SnapshotProduct DTO가 스냅샷 배열에서 올바르게 생성됩니다. */ public function test_snapshot_product_creates_from_array(): void { $snapshot = [ 'id' => 10, 'name' => '테스트 상품', 'selling_price' => 50000, 'tax_status' => 'taxable', 'tax_rate' => 10.0, ]; $product = new SnapshotProduct($snapshot, 5); $this->assertEquals(10, $product->id); $this->assertEquals('테스트 상품', $product->name); $this->assertEquals(50000, $product->selling_price); $this->assertEquals(ProductTaxStatus::TAXABLE, $product->tax_status); $this->assertEquals(10.0, $product->tax_rate); $this->assertEquals(5, $product->shipping_policy_id); $this->assertEquals('테스트 상품', $product->getLocalizedName()); $this->assertFalse($product->relationLoaded('categories')); } /** * SnapshotProductOption DTO가 스냅샷 배열에서 올바르게 생성됩니다. */ public function test_snapshot_product_option_creates_from_array(): void { $snapshot = [ 'id' => 20, 'selling_price' => 50000, 'price_adjustment' => 5000, 'weight' => 1.5, 'volume' => 2.0, 'mileage_value' => 3.0, 'mileage_type' => 'rate', 'option_name' => '빨강/XL', ]; $option = new SnapshotProductOption($snapshot, 50000); $this->assertEquals(20, $option->id); $this->assertEquals(50000, $option->selling_price); $this->assertEquals(5000, $option->price_adjustment); $this->assertEquals(55000, $option->getSellingPrice()); $this->assertEquals(55000, $option->getFinalPrice()); $this->assertEquals(1.5, $option->weight); $this->assertEquals(2.0, $option->volume); $this->assertEquals(3.0, $option->mileage_value); $this->assertEquals('rate', $option->mileage_type); $this->assertEquals('빨강/XL', $option->getLocalizedOptionName()); } /** * SnapshotProduct는 누락된 필드에 대해 기본값을 사용합니다. */ public function test_snapshot_product_uses_defaults_for_missing_fields(): void { $product = new SnapshotProduct([]); $this->assertEquals(0, $product->id); $this->assertEquals('', $product->name); $this->assertEquals(0, $product->selling_price); $this->assertEquals(ProductTaxStatus::TAXABLE, $product->tax_status); $this->assertNull($product->shipping_policy_id); } // ======================================== // Section 2: 스냅샷 모드 계산 (DB 조회 없이) // ======================================== /** * 스냅샷 모드에서 DB 조회 없이 옵션별 소계를 계산합니다. */ public function test_snapshot_mode_calculates_subtotal_without_db(): void { // Given: 스냅샷 데이터만으로 CalculationItem 구성 (DB에 해당 상품 없음) $productSnapshot = [ 'id' => 9999, 'name' => '스냅샷 상품', 'selling_price' => 30000, 'tax_status' => 'taxable', 'tax_rate' => 10.0, ]; $optionSnapshot = [ 'id' => 8888, 'selling_price' => 30000, 'price_adjustment' => 0, 'weight' => null, 'volume' => null, 'mileage_value' => null, 'mileage_type' => null, 'option_name' => '기본', ]; $input = new CalculationInput( items: [ new CalculationItem( productId: 9999, productOptionId: 8888, quantity: 3, productSnapshot: $productSnapshot, optionSnapshot: $optionSnapshot, ), ], metadata: ['snapshot_mode' => true], ); // When $result = $this->service->calculate($input); // Then: DB 조회 없이 스냅샷 기반 계산 성공 $this->assertCount(1, $result->items); $this->assertEquals(30000, $result->items[0]->unitPrice); $this->assertEquals(3, $result->items[0]->quantity); $this->assertEquals(90000, $result->items[0]->subtotal); } /** * 스냅샷 모드에서 가격 변경이 반영되지 않습니다 (주문 시점 가격 유지). */ public function test_snapshot_mode_uses_order_time_price_not_current(): void { // Given: DB에 상품 생성 (현재가 50,000원) $product = ProductFactory::new()->create([ 'tax_status' => 'taxable', 'selling_price' => 50000, ]); $option = ProductOptionFactory::new()->forProduct($product)->create([ 'price_adjustment' => 0, 'stock_quantity' => 100, ]); // 스냅샷에는 주문 시점 가격 (30,000원) $productSnapshot = [ 'id' => $product->id, 'name' => '주문 시 이름', 'selling_price' => 30000, 'tax_status' => 'taxable', ]; $optionSnapshot = [ 'id' => $option->id, 'selling_price' => 30000, 'price_adjustment' => 0, 'weight' => null, 'volume' => null, ]; $input = new CalculationInput( items: [ new CalculationItem( productId: $product->id, productOptionId: $option->id, quantity: 2, productSnapshot: $productSnapshot, optionSnapshot: $optionSnapshot, ), ], metadata: ['snapshot_mode' => true], ); // When $result = $this->service->calculate($input); // Then: 현재가(50,000)가 아닌 스냅샷 가격(30,000)으로 계산 $this->assertEquals(30000, $result->items[0]->unitPrice); $this->assertEquals(60000, $result->items[0]->subtotal); } // ======================================== // Section 3: 쿠폰 스냅샷 모드 // ======================================== /** * 테스트용 쿠폰과 발급 레코드를 생성합니다. * * @param array $couponOverrides 쿠폰 데이터 오버라이드 * @param array $issueOverrides 발급 레코드 오버라이드 * @return CouponIssue */ protected function createCouponIssue(array $couponOverrides = [], array $issueOverrides = []): CouponIssue { $coupon = Coupon::create(array_merge([ 'name' => ['ko' => '테스트 쿠폰', 'en' => 'Test Coupon'], 'description' => ['ko' => '테스트용', 'en' => 'Test'], '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), ], $couponOverrides)); $user = User::factory()->create(); return CouponIssue::create(array_merge([ 'coupon_id' => $coupon->id, 'user_id' => $user->id, 'coupon_code' => 'TEST'.uniqid(), 'status' => CouponIssueRecordStatus::AVAILABLE, 'issued_at' => now(), 'expired_at' => now()->addDays(30), ], $issueOverrides)); } /** * 스냅샷 모드에서 만료된 쿠폰도 재계산에 적용됩니다. */ public function test_snapshot_mode_applies_expired_coupon(): void { // Given: 만료된 쿠폰 생성 $couponIssue = $this->createCouponIssue( couponOverrides: [ 'discount_type' => CouponDiscountType::FIXED, 'discount_value' => 5000, 'valid_from' => now()->subDays(30), 'valid_to' => now()->subDays(1), // 어제 만료 ], issueOverrides: [ 'status' => CouponIssueRecordStatus::USED, 'expired_at' => now()->subDays(1), ], ); $productSnapshot = [ 'id' => 9999, 'name' => '테스트 상품', 'selling_price' => 50000, 'tax_status' => 'taxable', ]; $optionSnapshot = [ 'id' => 8888, 'selling_price' => 50000, 'price_adjustment' => 0, ]; // 쿠폰 스냅샷: 주문 시점의 규칙 $couponSnapshots = [ $couponIssue->id => [ 'discount_type' => 'fixed', 'discount_value' => 5000, 'min_order_amount' => 0, 'target_type' => 'product_amount', 'target_scope' => 'all', ], ]; $input = new CalculationInput( items: [ new CalculationItem( productId: 9999, productOptionId: 8888, quantity: 1, productSnapshot: $productSnapshot, optionSnapshot: $optionSnapshot, ), ], couponIssueIds: [$couponIssue->id], metadata: [ 'snapshot_mode' => true, 'coupon_snapshots' => $couponSnapshots, ], ); // When $result = $this->service->calculate($input); // Then: 만료 쿠폰이지만 스냅샷 모드에서 적용됨 $this->assertEquals(50000, $result->items[0]->subtotal); // 쿠폰 할인이 적용되었는지 확인 $this->assertNotEmpty($result->promotions->getAllCoupons()); $this->assertEquals(5000, $result->promotions->getAllCoupons()[0]->totalDiscount); } /** * 스냅샷 모드에서 쿠폰 스냅샷 규칙이 현재 쿠폰 규칙보다 우선합니다. */ public function test_snapshot_mode_uses_snapshot_coupon_rules_over_current(): void { // Given: 현재 쿠폰은 10% 할인으로 변경됨 $couponIssue = $this->createCouponIssue( couponOverrides: [ 'discount_type' => CouponDiscountType::RATE, 'discount_value' => 10, // 현재: 10% ], issueOverrides: [ 'status' => CouponIssueRecordStatus::USED, ], ); $productSnapshot = [ 'id' => 9999, 'name' => '테스트 상품', 'selling_price' => 100000, 'tax_status' => 'taxable', ]; $optionSnapshot = [ 'id' => 8888, 'selling_price' => 100000, 'price_adjustment' => 0, ]; // 주문 시점에는 3,000원 정액 할인이었음 $couponSnapshots = [ $couponIssue->id => [ 'discount_type' => 'fixed', 'discount_value' => 3000, 'min_order_amount' => 0, 'target_type' => 'product_amount', 'target_scope' => 'all', ], ]; $input = new CalculationInput( items: [ new CalculationItem( productId: 9999, productOptionId: 8888, quantity: 1, productSnapshot: $productSnapshot, optionSnapshot: $optionSnapshot, ), ], couponIssueIds: [$couponIssue->id], metadata: [ 'snapshot_mode' => true, 'coupon_snapshots' => $couponSnapshots, ], ); // When $result = $this->service->calculate($input); // Then: 현재 규칙(10%=10,000원)이 아닌 스냅샷 규칙(정액 3,000원)으로 계산 $appliedCoupons = $result->promotions->getAllCoupons(); $this->assertNotEmpty($appliedCoupons); $this->assertEquals(3000, $appliedCoupons[0]->totalDiscount); } // ======================================== // Section 4: 배송비 스냅샷 정책 // ======================================== /** * 스냅샷 모드에서 배송비가 스냅샷 정책으로 계산됩니다. */ public function test_snapshot_mode_calculates_shipping_with_snapshot_policy(): void { // Given: DB에 배송정책 생성 (현재: 5,000원) $policy = ShippingPolicy::create([ 'name' => ['ko' => '기본 배송', 'en' => 'Standard'], 'is_default' => false, 'is_active' => true, ]); $policy->countrySettings()->create([ 'country_code' => 'KR', 'shipping_method' => 'parcel', 'currency_code' => 'KRW', 'charge_policy' => ChargePolicyEnum::FIXED, 'base_fee' => 5000, 'is_active' => true, ]); $productSnapshot = [ 'id' => 9999, 'name' => '테스트 상품', 'selling_price' => 30000, 'tax_status' => 'taxable', ]; $optionSnapshot = [ 'id' => 8888, 'selling_price' => 30000, 'price_adjustment' => 0, 'weight' => 1.0, 'volume' => null, ]; // 스냅샷에는 주문 시점 배송비 (3,000원) $shippingPolicySnapshots = [ 8888 => [ 'policy_id' => $policy->id, 'policy_snapshot' => [ 'policy_name' => '기본 배송', 'country_code' => 'KR', 'charge_policy' => 'fixed', 'base_fee' => 3000, // 주문 시점: 3,000원 'conditional_free_threshold' => null, 'ranges' => null, 'extra_fee_template' => null, ], ], ]; $input = new CalculationInput( items: [ new CalculationItem( productId: 9999, productOptionId: 8888, quantity: 1, productSnapshot: $productSnapshot, optionSnapshot: $optionSnapshot, ), ], shippingAddress: new ShippingAddress(countryCode: 'KR'), shippingPolicySnapshots: $shippingPolicySnapshots, metadata: ['snapshot_mode' => true], ); // When $result = $this->service->calculate($input); // Then: 현재 배송비(5,000)가 아닌 스냅샷 배송비(3,000)로 계산 $this->assertEquals(3000, $result->summary->totalShipping); } // ======================================== // Section 5: 다통화 스냅샷 환율 변환 // ======================================== /** * 스냅샷 환율로 다통화 변환이 수행됩니다. */ public function test_convert_multiple_amounts_with_snapshot(): void { $amounts = ['refund_amount' => 50000]; // 50,000 KRW $currencySnapshot = [ 'base_currency' => 'KRW', 'order_currency' => 'USD', 'exchange_rate' => 1.20, 'exchange_rates' => [ 'KRW' => 1.0, 'USD' => 1.20, // 주문 시점: 1.20 (현재: 1.35) ], 'snapshot_at' => '2026-03-20T10:00:00+09:00', ]; // When $result = $this->currencyService->convertMultipleAmountsWithSnapshot($amounts, $currencySnapshot); // Then: 스냅샷 환율(1.20) 사용 $this->assertArrayHasKey('KRW', $result); $this->assertArrayHasKey('USD', $result); // KRW는 기본통화이므로 원본 그대로 $this->assertEquals(50000, $result['KRW']['refund_amount']); // USD: (50000 / 1000) × 1.20 = 60.00 $this->assertEquals(60.0, $result['USD']['refund_amount']); // 메타데이터에 snapshot_based 플래그 확인 $this->assertTrue($result['USD']['_meta']['snapshot_based']); $this->assertEquals(1.20, $result['USD']['_meta']['exchange_rate']); } /** * 스냅샷 환율이 현재 설정 환율과 다르게 변환됩니다. */ public function test_snapshot_exchange_rate_differs_from_current(): void { // 캐시 초기화 (이전 테스트 영향 방지) $this->currencyService->clearCache(); $amounts = ['amount' => 100000]; // 100,000 KRW // 스냅샷 환율: USD 1.00 $snapshotResult = $this->currencyService->convertMultipleAmountsWithSnapshot($amounts, [ 'base_currency' => 'KRW', 'exchange_rates' => ['KRW' => 1.0, 'USD' => 1.00], ]); // 현재 환율: USD 1.35 (설정에서) $currentResult = $this->currencyService->convertMultipleAmounts($amounts); // Then: 스냅샷 환율(1.00)과 현재 환율(1.35)이 다름 $snapshotUsd = $snapshotResult['USD']['amount']; // (100000/1000) × 1.00 = 100.00 $currentUsd = $currentResult['USD']['amount']; // (100000/1000) × 1.35 = 135.00 $this->assertEquals(100.0, $snapshotUsd); // 현재 환율과 스냅샷 환율이 다른 것만 검증 $this->assertNotEquals($snapshotUsd, $currentUsd); } // ======================================== // Section 6: ProductOption::toSnapshotArray 마일리지 필드 // ======================================== /** * ProductOption::toSnapshotArray()에 마일리지 필드가 포함됩니다. */ public function test_product_option_snapshot_includes_mileage_fields(): void { $product = ProductFactory::new()->create([ 'selling_price' => 30000, ]); $option = ProductOptionFactory::new()->forProduct($product)->create([ 'price_adjustment' => 0, 'stock_quantity' => 50, 'mileage_value' => 5.0, 'mileage_type' => 'rate', ]); // When $snapshot = $option->toSnapshotArray(); // Then $this->assertArrayHasKey('mileage_value', $snapshot); $this->assertArrayHasKey('mileage_type', $snapshot); $this->assertEquals(5.0, $snapshot['mileage_value']); $this->assertEquals('rate', $snapshot['mileage_type']); } }