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

541 lines
18 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use Modules\Sirsoft\Ecommerce\Exceptions\InsufficientStockException;
use Modules\Sirsoft\Ecommerce\Models\Order;
use Modules\Sirsoft\Ecommerce\Models\OrderOption;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Services\StockService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
/**
* 재고 관리 서비스 테스트
*/
class StockServiceTest extends ModuleTestCase
{
protected StockService $service;
protected function setUp(): void
{
parent::setUp();
$this->service = app(StockService::class);
}
public function test_validate_stock_returns_true_for_sufficient(): void
{
$product = Product::factory()->create();
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$result = $this->service->validateStock([
['product_option_id' => $option->id, 'quantity' => 5],
]);
$this->assertTrue($result);
}
public function test_validate_stock_returns_true_for_exact_amount(): void
{
$product = Product::factory()->create();
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$result = $this->service->validateStock([
['product_option_id' => $option->id, 'quantity' => 5],
]);
$this->assertTrue($result);
}
public function test_validate_stock_throws_for_insufficient(): void
{
$product = Product::factory()->create();
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 3,
]);
$this->expectException(InsufficientStockException::class);
$this->service->validateStock([
['product_option_id' => $option->id, 'quantity' => 5],
]);
}
public function test_validate_stock_throws_for_nonexistent_option(): void
{
$this->expectException(InsufficientStockException::class);
$this->service->validateStock([
['product_option_id' => 999999, 'quantity' => 1],
]);
}
public function test_validate_stock_multiple_options(): void
{
$product = Product::factory()->create();
$option1 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$option2 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 20,
]);
$result = $this->service->validateStock([
['product_option_id' => $option1->id, 'quantity' => 5],
['product_option_id' => $option2->id, 'quantity' => 10],
]);
$this->assertTrue($result);
}
public function test_deduct_option_stock_reduces_quantity(): void
{
$product = Product::factory()->create(['stock_quantity' => 10]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$result = $this->service->deductOptionStock($option->id, 3);
$this->assertTrue($result);
$option->refresh();
$this->assertEquals(7, $option->stock_quantity);
}
public function test_deduct_option_stock_to_zero(): void
{
$product = Product::factory()->create(['stock_quantity' => 5]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$result = $this->service->deductOptionStock($option->id, 5);
$this->assertTrue($result);
$option->refresh();
$this->assertEquals(0, $option->stock_quantity);
}
public function test_deduct_option_stock_throws_for_insufficient(): void
{
$product = Product::factory()->create(['stock_quantity' => 3]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 3,
]);
$this->expectException(InsufficientStockException::class);
$this->service->deductOptionStock($option->id, 5);
}
public function test_deduct_option_stock_throws_for_nonexistent(): void
{
$this->expectException(InsufficientStockException::class);
$this->service->deductOptionStock(999999, 1);
}
public function test_restore_option_stock_increases_quantity(): void
{
$product = Product::factory()->create(['stock_quantity' => 5]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$result = $this->service->restoreOptionStock($option->id, 3);
$this->assertTrue($result);
$option->refresh();
$this->assertEquals(8, $option->stock_quantity);
}
public function test_restore_option_stock_from_zero(): void
{
$product = Product::factory()->create(['stock_quantity' => 0]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 0,
]);
$result = $this->service->restoreOptionStock($option->id, 10);
$this->assertTrue($result);
$option->refresh();
$this->assertEquals(10, $option->stock_quantity);
}
public function test_deduct_stock_for_order(): void
{
$product = Product::factory()->create(['stock_quantity' => 20]);
$option1 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$option2 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$order = Order::factory()->create();
$orderOption1 = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option1->id,
'quantity' => 3,
]);
$orderOption2 = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option2->id,
'quantity' => 5,
]);
$order->load('options');
$this->service->deductStock($order);
$option1->refresh();
$option2->refresh();
$this->assertEquals(7, $option1->stock_quantity);
$this->assertEquals(5, $option2->stock_quantity);
// is_stock_deducted 플래그 확인
$orderOption1->refresh();
$orderOption2->refresh();
$this->assertTrue($orderOption1->is_stock_deducted);
$this->assertTrue($orderOption2->is_stock_deducted);
}
public function test_deduct_stock_throws_for_insufficient_order(): void
{
$product = Product::factory()->create(['stock_quantity' => 3]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 3,
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 10,
]);
$order->load('options');
$this->expectException(InsufficientStockException::class);
$this->service->deductStock($order);
}
public function test_restore_stock_for_order(): void
{
$product = Product::factory()->create(['stock_quantity' => 5]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$order = Order::factory()->create();
$orderOption = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'is_stock_deducted' => true,
]);
$order->load('options');
$this->service->restoreStock($order);
$option->refresh();
$this->assertEquals(8, $option->stock_quantity);
// is_stock_deducted 플래그가 false로 리셋 확인
$orderOption->refresh();
$this->assertFalse($orderOption->is_stock_deducted);
}
public function test_restore_stock_for_order_with_multiple_options(): void
{
$product = Product::factory()->create(['stock_quantity' => 10]);
$option1 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$option2 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option1->id,
'quantity' => 3,
'is_stock_deducted' => true,
]);
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option2->id,
'quantity' => 5,
'is_stock_deducted' => true,
]);
$order->load('options');
$this->service->restoreStock($order);
$option1->refresh();
$option2->refresh();
$this->assertEquals(8, $option1->stock_quantity);
$this->assertEquals(10, $option2->stock_quantity);
}
// ===== is_stock_deducted 멱등성 테스트 =====
public function test_deduct_stock_skips_already_deducted_options(): void
{
$product = Product::factory()->create(['stock_quantity' => 20]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'is_stock_deducted' => true, // 이미 차감됨
]);
$order->load('options');
$this->service->deductStock($order);
// 이미 차감된 옵션은 스킵 → 재고 변동 없음
$option->refresh();
$this->assertEquals(10, $option->stock_quantity);
}
public function test_deduct_stock_partial_idempotency(): void
{
$product = Product::factory()->create(['stock_quantity' => 20]);
$option1 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$option2 = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option1->id,
'quantity' => 3,
'is_stock_deducted' => true, // 이미 차감됨
]);
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option2->id,
'quantity' => 5,
'is_stock_deducted' => false, // 미차감
]);
$order->load('options');
$this->service->deductStock($order);
// option1: 이미 차감 → 스킵 (재고 유지)
$option1->refresh();
$this->assertEquals(10, $option1->stock_quantity);
// option2: 미차감 → 차감 실행
$option2->refresh();
$this->assertEquals(5, $option2->stock_quantity);
}
public function test_restore_stock_skips_non_deducted_options(): void
{
$product = Product::factory()->create(['stock_quantity' => 10]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'is_stock_deducted' => false, // 차감되지 않음
]);
$order->load('options');
$this->service->restoreStock($order);
// 차감되지 않은 옵션은 복원 스킵 → 재고 변동 없음
$option->refresh();
$this->assertEquals(5, $option->stock_quantity);
}
// ===== restoreOptionStockForOrderOption (복원 + 플래그 정리) =====
public function test_restore_option_for_order_option_increases_stock_and_resets_flag(): void
{
$product = Product::factory()->create(['stock_quantity' => 5]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$order = Order::factory()->create();
// 전체취소 시나리오: CANCELLED 상태 + is_stock_deducted=true 인 옵션
$orderOption = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'option_status' => \Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum::CANCELLED,
'is_stock_deducted' => true,
]);
$this->service->restoreOptionStockForOrderOption($orderOption, 3);
// 재고 복원 (5 + 3 = 8)
$option->refresh();
$this->assertEquals(8, $option->stock_quantity);
// CANCELLED 옵션 플래그 false 로 정리
$orderOption->refresh();
$this->assertFalse($orderOption->is_stock_deducted);
}
public function test_restore_option_for_order_option_keeps_flag_on_remaining_active_option(): void
{
$product = Product::factory()->create(['stock_quantity' => 10]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 5,
]);
$order = Order::factory()->create();
// 부분취소 분할 결과 모사: 동일 product_option_id 에 대해
// 잔여(active) 행 + 취소(CANCELLED) 행이 공존
$activeOption = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 2,
'option_status' => \Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum::PAYMENT_COMPLETE,
'is_stock_deducted' => true,
]);
$cancelledOption = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'option_status' => \Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum::CANCELLED,
'is_stock_deducted' => true,
]);
// 취소 수량 3 복원 (원본 행 컨텍스트로 호출 — product_option_id 기준 CANCELLED 행 정리)
$this->service->restoreOptionStockForOrderOption($cancelledOption, 3);
$option->refresh();
$this->assertEquals(8, $option->stock_quantity); // 5 + 3
// CANCELLED 행만 플래그 정리, 잔여 active 행은 유지
$cancelledOption->refresh();
$activeOption->refresh();
$this->assertFalse($cancelledOption->is_stock_deducted);
$this->assertTrue($activeOption->is_stock_deducted);
}
// ===== redeductForReactivation (재활성 재차감) =====
public function test_rededuct_for_reactivation_rededucts_restored_stock(): void
{
$product = Product::factory()->create(['stock_quantity' => 8]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 8, // 취소로 복원된 상태(원래 5에서 +3)
]);
$order = Order::factory()->create();
// 취소로 복원된 옵션 (is_stock_deducted=false)
$orderOption = OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'option_status' => \Modules\Sirsoft\Ecommerce\Enums\OrderStatusEnum::CANCELLED,
'is_stock_deducted' => false,
]);
$order->load('options');
$this->service->redeductForReactivation($order);
// 재차감으로 재고 -3 (8 → 5)
$option->refresh();
$this->assertEquals(5, $option->stock_quantity);
// 재차감 후 플래그 true
$orderOption->refresh();
$this->assertTrue($orderOption->is_stock_deducted);
}
public function test_rededuct_for_reactivation_skips_already_deducted_via_idempotency(): void
{
$product = Product::factory()->create(['stock_quantity' => 10]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 10,
]);
$order = Order::factory()->create();
// 복원되지 않았던 옵션 (OFF 등으로 여전히 차감 상태) → 멱등 스킵
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'is_stock_deducted' => true,
]);
$order->load('options');
$this->service->redeductForReactivation($order);
// 이미 차감된 옵션은 멱등 스킵 → 재고 불변
$option->refresh();
$this->assertEquals(10, $option->stock_quantity);
}
public function test_rededuct_for_reactivation_throws_on_insufficient_stock(): void
{
$product = Product::factory()->create(['stock_quantity' => 2]);
$option = ProductOption::factory()->create([
'product_id' => $product->id,
'stock_quantity' => 2, // 재차감 요구량(3)보다 적음
]);
$order = Order::factory()->create();
OrderOption::factory()->create([
'order_id' => $order->id,
'product_option_id' => $option->id,
'quantity' => 3,
'is_stock_deducted' => false,
]);
$order->load('options');
$this->expectException(InsufficientStockException::class);
$this->service->redeductForReactivation($order);
}
}