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

2006 lines
68 KiB
PHP

<?php
namespace Modules\Sirsoft\Ecommerce\Tests\Unit\Services;
use App\Models\User;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Queue;
use Mockery;
use Modules\Sirsoft\Ecommerce\Database\Factories\CartFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductFactory;
use Modules\Sirsoft\Ecommerce\Database\Factories\ProductOptionFactory;
use Modules\Sirsoft\Ecommerce\DTO\CartWithCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\OrderCalculationResult;
use Modules\Sirsoft\Ecommerce\DTO\Summary;
use Modules\Sirsoft\Ecommerce\Exceptions\CartUnavailableException;
use Modules\Sirsoft\Ecommerce\Models\Cart;
use Modules\Sirsoft\Ecommerce\Models\Product;
use Modules\Sirsoft\Ecommerce\Models\ProductOption;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\CartRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\OrderRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductOptionRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Repositories\Contracts\ProductRepositoryInterface;
use Modules\Sirsoft\Ecommerce\Services\AdditionalOptionSelectionService;
use Modules\Sirsoft\Ecommerce\Services\CartService;
use Modules\Sirsoft\Ecommerce\Services\OrderCalculationService;
use Modules\Sirsoft\Ecommerce\Tests\ModuleTestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use ReflectionMethod;
/**
* 장바구니 서비스 Unit 테스트
*/
class CartServiceTest extends ModuleTestCase
{
protected CartService $service;
protected $mockCartRepository;
protected $mockProductOptionRepository;
protected $mockCalculationService;
protected $mockOrderRepository;
protected $mockProductRepository;
protected function setUp(): void
{
parent::setUp();
// Unit 테스트는 Mock 모델(DB 미저장) 사용. Hook listener 가 큐 워커에서
// 모델을 deserialize 할 때 find($id) 가 null 을 반환하여 TypeError 발생.
// Queue::fake() 로 listener job 실행을 차단하여 Service 의 비즈니스 로직만 검증.
Queue::fake();
$this->mockCartRepository = Mockery::mock(CartRepositoryInterface::class);
$this->mockProductOptionRepository = Mockery::mock(ProductOptionRepositoryInterface::class);
$this->mockCalculationService = Mockery::mock(OrderCalculationService::class);
$this->mockOrderRepository = Mockery::mock(OrderRepositoryInterface::class);
$this->mockProductRepository = Mockery::mock(ProductRepositoryInterface::class);
// 추가옵션 선택 검증 서비스 — 기존 테스트는 추가옵션 미선택 전제이므로
// 빈 선택은 빈 배열을 반환하도록 실제 구현(DB 조회)을 사용한다.
$additionalOptionSelectionService = app(AdditionalOptionSelectionService::class);
$this->service = new CartService(
$this->mockCartRepository,
$this->mockProductOptionRepository,
$this->mockCalculationService,
$this->mockOrderRepository,
$this->mockProductRepository,
$additionalOptionSelectionService
);
// 판매상태/구매수량 검증용 기본 스텁 (개별 테스트가 명시 오버라이드 가능).
// 기존 테스트는 판매중(on_sale)·전시중 상품 + 한도 없음을 가정하므로
// DB에 영속된 옵션/상품을 실제 조회해 통과시킨다.
$this->mockProductOptionRepository
->shouldReceive('findByIdWithProduct')
->andReturnUsing(fn ($id) => ProductOption::with('product')->find($id))
->byDefault();
$this->mockProductOptionRepository
->shouldReceive('findById')
->andReturnUsing(fn ($id) => ProductOption::find($id))
->byDefault();
$this->mockCartRepository
->shouldReceive('sumQuantityByProduct')
->andReturn(0)
->byDefault();
$this->mockProductRepository
->shouldReceive('find')
->andReturnUsing(fn ($id) => Product::find($id))
->byDefault();
}
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
/**
* private 메서드를 호출합니다.
*
* @param object $object 대상 객체
* @param string $methodName 메서드 이름
* @param array $args 메서드 인자
*/
protected function invokePrivateMethod(object $object, string $methodName, array $args = []): mixed
{
$method = new ReflectionMethod($object, $methodName);
$method->setAccessible(true);
return $method->invokeArgs($object, $args);
}
/**
* CartService의 addToCart private 메서드를 호출합니다.
*
* @param array $data 장바구니 데이터
*/
protected function callAddToCart(array $data): Cart
{
return $this->invokePrivateMethod($this->service, 'addToCart', [$data]);
}
// ========================================
// issueCartKey() 테스트
// ========================================
public function test_issue_cart_key_returns_valid_key(): void
{
// Given: 중복 키 없음
$this->mockCartRepository
->shouldReceive('existsByCartKey')
->once()
->andReturn(false);
// When
$cartKey = $this->service->issueCartKey();
// Then
$this->assertStringStartsWith('ck_', $cartKey);
$this->assertEquals(35, strlen($cartKey)); // 'ck_' + 32자 = 35자
}
public function test_issue_cart_key_returns_unique_keys(): void
{
// Given: 중복 키 없음
$this->mockCartRepository
->shouldReceive('existsByCartKey')
->twice()
->andReturn(false);
// When
$key1 = $this->service->issueCartKey();
$key2 = $this->service->issueCartKey();
// Then
$this->assertNotEquals($key1, $key2);
}
public function test_issue_cart_key_regenerates_on_duplicate(): void
{
// Given: 첫 번째 키는 중복, 두 번째 키는 유일
$this->mockCartRepository
->shouldReceive('existsByCartKey')
->andReturn(true, false);
// When
$cartKey = $this->service->issueCartKey();
// Then: 최종적으로 유효한 키 반환
$this->assertStringStartsWith('ck_', $cartKey);
$this->assertEquals(35, strlen($cartKey));
}
// ========================================
// getCart() 테스트
// ========================================
public function test_get_cart_returns_items_for_user(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->forUser($user)->forOption($option)->create();
$items = new Collection([$cart]);
$this->mockCartRepository
->shouldReceive('findByUserId')
->with($user->id)
->once()
->andReturn($items);
// When
$result = $this->service->getCart($user->id, null);
// Then
$this->assertCount(1, $result);
$this->assertEquals($cart->id, $result->first()->id);
}
public function test_get_cart_returns_items_for_guest(): void
{
// Given
$cartKey = 'ck_test_guest_key';
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->withCartKey($cartKey)->forOption($option)->create();
$items = new Collection([$cart]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn($items);
// When
$result = $this->service->getCart(null, $cartKey);
// Then
$this->assertCount(1, $result);
$this->assertEquals($cartKey, $result->first()->cart_key);
}
public function test_get_cart_returns_empty_collection_without_credentials(): void
{
// When
$result = $this->service->getCart(null, null);
// Then
$this->assertInstanceOf(Collection::class, $result);
$this->assertCount(0, $result);
}
// ========================================
// getCartWithCalculation() 테스트
// ========================================
public function test_get_cart_with_calculation_returns_items_and_calculation(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 2,
]);
$items = new Collection([$cart]);
$this->mockCartRepository
->shouldReceive('findByUserId')
->with($user->id)
->once()
->andReturn($items);
$calculationResult = new OrderCalculationResult(
items: [],
summary: new Summary(
subtotal: 30000,
productCouponDiscount: 0,
codeDiscount: 0,
orderCouponDiscount: 0,
totalDiscount: 0,
baseShippingTotal: 3000,
extraShippingTotal: 0,
totalShipping: 3000,
shippingDiscount: 0,
taxableAmount: 30000,
taxFreeAmount: 0,
pointsEarning: 300,
pointsUsed: 0,
paymentAmount: 33000,
finalAmount: 33000
)
);
$this->mockCalculationService
->shouldReceive('calculate')
->once()
->andReturn($calculationResult);
// When
$result = $this->service->getCartWithCalculation($user->id, null);
// Then
$this->assertInstanceOf(CartWithCalculationResult::class, $result);
$this->assertCount(1, $result->items);
$this->assertEquals(33000, $result->calculation->summary->finalAmount);
}
public function test_get_cart_with_calculation_for_guest(): void
{
// Given
$cartKey = 'ck_test_guest_calculation';
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->withCartKey($cartKey)->forOption($option)->create();
$items = new Collection([$cart]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn($items);
$calculationResult = new OrderCalculationResult(
items: [],
summary: new Summary(
subtotal: 15000,
productCouponDiscount: 0,
codeDiscount: 0,
orderCouponDiscount: 0,
totalDiscount: 0,
baseShippingTotal: 3000,
extraShippingTotal: 0,
totalShipping: 3000,
shippingDiscount: 0,
taxableAmount: 15000,
taxFreeAmount: 0,
pointsEarning: 150,
pointsUsed: 0,
paymentAmount: 18000,
finalAmount: 18000
)
);
$this->mockCalculationService
->shouldReceive('calculate')
->once()
->andReturn($calculationResult);
// When
$result = $this->service->getCartWithCalculation(null, $cartKey);
// Then
$this->assertInstanceOf(CartWithCalculationResult::class, $result);
$this->assertEquals(18000, $result->calculation->summary->finalAmount);
}
public function test_get_cart_with_calculation_returns_empty_for_empty_cart(): void
{
// Given
$user = User::factory()->create();
$emptyItems = new Collection;
$this->mockCartRepository
->shouldReceive('findByUserId')
->with($user->id)
->once()
->andReturn($emptyItems);
// 비정상 항목 제외(U13②/U4) 후 계산 대상이 없으면 calculate 호출 없이
// 빈 계산 결과를 반환한다. (빈 장바구니 → 즉시 빈 결과)
$this->mockCalculationService
->shouldReceive('calculate')
->never();
// When
$result = $this->service->getCartWithCalculation($user->id, null);
// Then
$this->assertTrue($result->isEmpty());
$this->assertEquals(0, $result->count());
$this->assertEquals(0, $result->calculation->summary->finalAmount);
}
public function test_get_cart_with_calculation_passes_coupons_and_points(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->forUser($user)->forOption($option)->create();
$items = new Collection([$cart]);
$couponIds = [1, 2];
$usePoints = 1000;
$this->mockCartRepository
->shouldReceive('findByUserId')
->with($user->id)
->once()
->andReturn($items);
$calculationResult = new OrderCalculationResult(
items: [],
summary: new Summary(
subtotal: 30000,
productCouponDiscount: 5000,
codeDiscount: 0,
orderCouponDiscount: 0,
totalDiscount: 5000,
baseShippingTotal: 0,
extraShippingTotal: 0,
totalShipping: 0,
shippingDiscount: 0,
taxableAmount: 25000,
taxFreeAmount: 0,
pointsEarning: 250,
pointsUsed: 1000,
paymentAmount: 24000,
finalAmount: 24000
)
);
$this->mockCalculationService
->shouldReceive('calculate')
->withArgs(function ($input) use ($couponIds, $usePoints) {
return $input->couponIssueIds === $couponIds
&& $input->usePoints === $usePoints;
})
->once()
->andReturn($calculationResult);
// When
$result = $this->service->getCartWithCalculation($user->id, null, $couponIds, $usePoints);
// Then
$this->assertEquals(5000, $result->calculation->summary->productCouponDiscount);
$this->assertEquals(1000, $result->calculation->summary->pointsUsed);
$this->assertEquals(24000, $result->calculation->summary->finalAmount);
}
// ========================================
// addToCart() 테스트
// ========================================
public function test_add_to_cart_creates_new_item(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$data = [
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
];
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection);
// Mock stock validation
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
$newCart = new Cart($data);
$newCart->id = 1;
$this->mockCartRepository
->shouldReceive('create')
->once()
->andReturn($newCart);
// When
$result = $this->callAddToCart($data);
// Then
$this->assertEquals($user->id, $result->user_id);
$this->assertEquals($option->id, $result->product_option_id);
}
public function test_add_to_cart_increases_quantity_for_existing_item(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$existingCart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 2,
]);
$data = [
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 3,
];
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection([$existingCart]));
// Mock stock validation
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
$updatedCart = clone $existingCart;
$updatedCart->quantity = 5; // 2 + 3
$this->mockCartRepository
->shouldReceive('update')
->once()
->andReturn($updatedCart);
// When
$result = $this->callAddToCart($data);
// Then
$this->assertEquals(5, $result->quantity);
}
public function test_add_to_cart_for_guest(): void
{
// Given
$cartKey = 'ck_guest_add';
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$data = [
'cart_key' => $cartKey,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
];
$this->mockCartRepository
->shouldReceive('findAllByCartKeyAndOption')
->with($cartKey, $option->id)
->once()
->andReturn(new Collection);
// Mock stock validation
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
$newCart = new Cart($data);
$newCart->id = 1;
$this->mockCartRepository
->shouldReceive('create')
->once()
->andReturn($newCart);
// When
$result = $this->callAddToCart($data);
// Then
$this->assertEquals($cartKey, $result->cart_key);
}
// ========================================
// updateQuantity() 테스트
// ========================================
public function test_update_quantity_updates_quantity(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$cart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 1,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock stock validation
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
$updatedCart = clone $cart;
$updatedCart->quantity = 5;
$this->mockCartRepository
->shouldReceive('update')
->once()
->andReturn($updatedCart);
// When
$result = $this->service->updateQuantity($cart->id, 5, $user->id, null);
// Then
$this->assertEquals(5, $result->quantity);
}
public function test_update_quantity_throws_exception_for_not_found(): void
{
// Given
$this->mockCartRepository
->shouldReceive('find')
->with(99999)
->once()
->andReturn(null);
// Then
$this->expectException(\Exception::class);
// When
$this->service->updateQuantity(99999, 5, 1, null);
}
public function test_update_quantity_throws_exception_for_unauthorized(): void
{
// Given
$user = User::factory()->create();
$otherUser = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->forUser($user)->forOption($option)->create();
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Then
$this->expectException(\Exception::class);
// When
$this->service->updateQuantity($cart->id, 5, $otherUser->id, null);
}
// ========================================
// deleteItem() 테스트
// ========================================
public function test_delete_item_deletes_cart_item(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create();
$cart = CartFactory::new()->forUser($user)->forOption($option)->create();
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
$this->mockCartRepository
->shouldReceive('delete')
->with($cart)
->once()
->andReturn(true);
// When
$result = $this->service->deleteItem($cart->id, $user->id, null);
// Then
$this->assertTrue($result);
}
// ========================================
// deleteItems() 테스트
// ========================================
public function test_delete_items_deletes_multiple_items(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option1 = ProductOptionFactory::new()->forProduct($product)->create();
$option2 = ProductOptionFactory::new()->forProduct($product)->create();
$cart1 = CartFactory::new()->forUser($user)->forOption($option1)->create();
$cart2 = CartFactory::new()->forUser($user)->forOption($option2)->create();
$ids = [$cart1->id, $cart2->id];
$items = new Collection([$cart1, $cart2]);
$this->mockCartRepository
->shouldReceive('findByIds')
->with($ids)
->once()
->andReturn($items);
$this->mockCartRepository
->shouldReceive('deleteByIds')
->once()
->andReturn(2);
// When
$result = $this->service->deleteItems($ids, $user->id, null);
// Then
$this->assertEquals(2, $result);
}
// ========================================
// mergeGuestCartToUser() 테스트
// ========================================
public function test_merge_guest_cart_to_user_merges_items(): void
{
// Given
$user = User::factory()->create();
$cartKey = 'ck_merge_test';
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$guestCart = CartFactory::new()->withCartKey($cartKey)->forOption($option)->create([
'quantity' => 2,
]);
$guestItems = new Collection([$guestCart]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn($guestItems);
// Mock stock check in mergeGuestCartToUser
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->andReturn($option);
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection);
$this->mockCartRepository
->shouldReceive('update')
->once()
->andReturn($guestCart);
// When
$result = $this->service->mergeGuestCartToUser($cartKey, $user->id);
// Then
$this->assertEquals(1, $result);
}
public function test_merge_guest_cart_combines_quantities_for_same_option(): void
{
// Given
$user = User::factory()->create();
$cartKey = 'ck_merge_combine';
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$guestCart = CartFactory::new()->withCartKey($cartKey)->forOption($option)->create([
'quantity' => 2,
]);
$userCart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 3,
]);
$guestItems = new Collection([$guestCart]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn($guestItems);
// Mock stock check in mergeGuestCartToUser
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->andReturn($option);
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection([$userCart]));
$combinedCart = clone $userCart;
$combinedCart->quantity = 5;
$this->mockCartRepository
->shouldReceive('update')
->with($userCart, Mockery::any())
->once()
->andReturn($combinedCart);
$this->mockCartRepository
->shouldReceive('delete')
->with($guestCart)
->once();
// When
$result = $this->service->mergeGuestCartToUser($cartKey, $user->id);
// Then
$this->assertEquals(1, $result);
}
// ========================================
// getItemCount() 테스트
// ========================================
public function test_get_item_count_returns_count(): void
{
// Given
$user = User::factory()->create();
$this->mockCartRepository
->shouldReceive('countItems')
->with($user->id, null)
->once()
->andReturn(5);
// When
$result = $this->service->getItemCount($user->id, null);
// Then
$this->assertEquals(5, $result);
}
// ========================================
// 동일 상품 다른 옵션 담기 테스트
// ========================================
/**
* 테스트 #86: 동일 상품 다른 옵션 담기
*
* 입력: 상품A-옵션1 존재, 상품A-옵션2 담기
* 기대: 별도 Cart 레코드 생성 (총 2개)
*/
public function test_add_to_cart_creates_separate_record_for_different_option(): void
{
// Given
$cartKey = 'ck_test123456789012345678901234';
$product = ProductFactory::new()->create();
$option1 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$option2 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
// 기존 장바구니 아이템 (옵션1)
$existingCart = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $option1->id,
'quantity' => 2,
]);
// 새 옵션(옵션2)에 대해서는 기존 아이템 없음
$this->mockCartRepository
->shouldReceive('findAllByCartKeyAndOption')
->with($cartKey, $option2->id)
->once()
->andReturn(new Collection);
// Mock stock validation
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option2->id)
->once()
->andReturn($option2);
// 새 아이템 생성
$newCart = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $option2->id,
'quantity' => 1,
]);
$this->mockCartRepository
->shouldReceive('create')
->once()
->andReturn($newCart);
// When
$result = $this->callAddToCart([
'cart_key' => $cartKey,
'product_id' => $product->id,
'product_option_id' => $option2->id,
'quantity' => 1,
]);
// Then
$this->assertEquals($option2->id, $result->product_option_id);
$this->assertEquals(1, $result->quantity);
}
// ========================================
// 옵션 변경 테스트 (#92, #93, #94)
// ========================================
/**
* 테스트 #92: 옵션만 변경
*
* 입력: PUT /cart/{id}, product_option_id: 새옵션, quantity: 기존
* 기대: 옵션 ID 업데이트
*/
public function test_change_option_updates_option_only(): void
{
// Given
$cartKey = 'ck_test123456789012345678901234';
$product = ProductFactory::new()->create();
$option1 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$option2 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$cart = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $option1->id,
'quantity' => 2,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock validateSameProduct and validateStock
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option2->id)
->andReturn($option2);
// 새 옵션이 이미 장바구니에 없음
$this->mockCartRepository
->shouldReceive('findAllByCartKeyAndOption')
->with($cartKey, $option2->id)
->once()
->andReturn(new Collection);
$updatedCart = clone $cart;
$updatedCart->product_option_id = $option2->id;
$this->mockCartRepository
->shouldReceive('update')
->with($cart, Mockery::on(function ($data) use ($option2) {
return $data['product_option_id'] === $option2->id && $data['quantity'] === 2;
}))
->once()
->andReturn($updatedCart);
// When
$result = $this->service->changeOption($cart->id, $option2->id, 2, null, $cartKey);
// Then
$this->assertEquals($option2->id, $result->product_option_id);
}
/**
* 테스트 #93: 옵션+수량 동시 변경
*
* 입력: PUT /cart/{id}, product_option_id: 새옵션, quantity: 3
* 기대: 옵션+수량 모두 업데이트
*/
public function test_change_option_updates_option_and_quantity(): void
{
// Given
$cartKey = 'ck_test123456789012345678901234';
$product = ProductFactory::new()->create();
$option1 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$option2 = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$cart = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $option1->id,
'quantity' => 1,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock validateSameProduct and validateStock
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option2->id)
->andReturn($option2);
$this->mockCartRepository
->shouldReceive('findAllByCartKeyAndOption')
->with($cartKey, $option2->id)
->once()
->andReturn(new Collection);
$updatedCart = clone $cart;
$updatedCart->product_option_id = $option2->id;
$updatedCart->quantity = 3;
$this->mockCartRepository
->shouldReceive('update')
->with($cart, Mockery::on(function ($data) use ($option2) {
return $data['product_option_id'] === $option2->id && $data['quantity'] === 3;
}))
->once()
->andReturn($updatedCart);
// When
$result = $this->service->changeOption($cart->id, $option2->id, 3, null, $cartKey);
// Then
$this->assertEquals($option2->id, $result->product_option_id);
$this->assertEquals(3, $result->quantity);
}
/**
* 테스트 #94: 옵션 변경 시 기존 동일옵션 존재 - 합산
*
* 입력: 기존: 옵션A(2개), 옵션B(3개) → 옵션A를 옵션B로 변경
* 기대: 옵션B: 5개, 옵션A 레코드 삭제
*
* 참고: changeOption에서 existingItem->fresh()를 호출하여 최신 데이터를 반환하므로
* update 호출 시 반환되는 객체의 quantity가 5인지 확인합니다.
*/
public function test_change_option_merges_when_target_option_exists(): void
{
// Given
$cartKey = 'ck_test123456789012345678901234';
$product = ProductFactory::new()->create();
$optionA = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$optionB = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
// 옵션A 장바구니 (변경 대상)
$cartA = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $optionA->id,
'quantity' => 2,
]);
// 옵션B 장바구니 (이미 존재) - Partial Mock으로 fresh() 동작 처리
$cartB = Mockery::mock(Cart::class)->makePartial();
$cartB->id = 999;
$cartB->cart_key = $cartKey;
$cartB->user_id = null;
$cartB->product_id = $product->id;
$cartB->product_option_id = $optionB->id;
$cartB->quantity = 3;
$this->mockCartRepository
->shouldReceive('find')
->with($cartA->id)
->once()
->andReturn($cartA);
// Mock validateSameProduct and validateStock
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($optionB->id)
->andReturn($optionB);
// 옵션B가 이미 장바구니에 존재
$this->mockCartRepository
->shouldReceive('findAllByCartKeyAndOption')
->with($cartKey, $optionB->id)
->once()
->andReturn(new Collection([$cartB]));
// update 결과로 quantity가 5인 객체 반환
$mergedCart = Mockery::mock(Cart::class)->makePartial();
$mergedCart->id = 999;
$mergedCart->product_option_id = $optionB->id;
$mergedCart->quantity = 5;
$this->mockCartRepository
->shouldReceive('update')
->with($cartB, Mockery::on(function ($data) {
return $data['quantity'] === 5; // 2 + 3 = 5
}))
->once()
->andReturn($mergedCart);
$this->mockCartRepository
->shouldReceive('delete')
->with($cartA)
->once();
// existingItem->fresh() 호출 시 mergedCart 반환
$cartB->shouldReceive('fresh')->once()->andReturn($mergedCart);
// When
$result = $this->service->changeOption($cartA->id, $optionB->id, 2, null, $cartKey);
// Then
$this->assertEquals($optionB->id, $result->product_option_id);
$this->assertEquals(5, $result->quantity);
}
// ========================================
// 비회원→회원 병합 (혼합) 테스트
// ========================================
/**
* 테스트 #106: 비회원→회원 병합 (혼합)
*
* 입력: 비회원: 옵션A 2개, 옵션B 1개 / 회원: 옵션A 3개, 옵션C 2개
* 기대: 옵션A 5개, 옵션B 1개, 옵션C 2개
*/
public function test_merge_guest_cart_to_user_with_mixed_items(): void
{
// Given
$user = User::factory()->create();
$cartKey = 'ck_test123456789012345678901234';
$product = ProductFactory::new()->create();
$optionA = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$optionB = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$optionC = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
// 비회원 장바구니: 옵션A 2개, 옵션B 1개
$guestCartA = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $optionA->id,
'quantity' => 2,
]);
$guestCartB = CartFactory::new()->create([
'cart_key' => $cartKey,
'user_id' => null,
'product_id' => $product->id,
'product_option_id' => $optionB->id,
'quantity' => 1,
]);
// 회원 장바구니: 옵션A 3개, 옵션C 2개
$userCartA = CartFactory::new()->forUser($user)->create([
'product_id' => $product->id,
'product_option_id' => $optionA->id,
'quantity' => 3,
]);
$userCartC = CartFactory::new()->forUser($user)->create([
'product_id' => $product->id,
'product_option_id' => $optionC->id,
'quantity' => 2,
]);
$guestItems = new Collection([$guestCartA, $guestCartB]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn($guestItems);
// Mock stock check in mergeGuestCartToUser
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($optionA->id)
->andReturn($optionA);
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($optionB->id)
->andReturn($optionB);
// 옵션A: 회원 장바구니에 존재 → 합산
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $optionA->id)
->once()
->andReturn(new Collection([$userCartA]));
$mergedCartA = clone $userCartA;
$mergedCartA->quantity = 5;
$this->mockCartRepository
->shouldReceive('update')
->with($userCartA, Mockery::on(function ($data) {
return $data['quantity'] === 5;
}))
->once()
->andReturn($mergedCartA);
$this->mockCartRepository
->shouldReceive('delete')
->with($guestCartA)
->once();
// 옵션B: 회원 장바구니에 없음 → user_id 업데이트
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $optionB->id)
->once()
->andReturn(new Collection);
$updatedGuestCartB = clone $guestCartB;
$updatedGuestCartB->user_id = $user->id;
$updatedGuestCartB->cart_key = null;
$this->mockCartRepository
->shouldReceive('update')
->with($guestCartB, Mockery::on(function ($data) use ($user) {
// cart_key는 유지되므로 업데이트 데이터에 포함되지 않음
return $data['user_id'] === $user->id && ! array_key_exists('cart_key', $data);
}))
->once()
->andReturn($updatedGuestCartB);
// When
$result = $this->service->mergeGuestCartToUser($cartKey, $user->id);
// Then: 2개 아이템 처리됨
$this->assertEquals(2, $result);
}
// ========================================
// #108 cart_key 없이 로그인 병합 테스트
// ========================================
/**
* #108 빈 cart_key로 병합 시도 시 병합을 스킵하고 0을 반환합니다.
*
* 빈 cart_key (빈 문자열)로 mergeGuestCartToUser 호출 시
* 비회원 장바구니가 없으므로 병합 없이 0을 반환해야 합니다.
*/
public function test_merge_guest_cart_skips_when_cart_key_is_empty(): void
{
// Given: 빈 cart_key
$user = User::factory()->create();
$emptyCartKey = '';
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($emptyCartKey)
->once()
->andReturn(new Collection);
// When: 빈 cart_key로 병합 시도
$result = $this->service->mergeGuestCartToUser($emptyCartKey, $user->id);
// Then: 병합된 아이템 0개
$this->assertEquals(0, $result);
}
/**
* #108 존재하지 않는 cart_key로 병합 시도 시 병합을 스킵합니다.
*
* 존재하지 않는 cart_key로 mergeGuestCartToUser 호출 시
* 비회원 장바구니가 없으므로 병합 없이 0을 반환해야 합니다.
*/
public function test_merge_guest_cart_skips_when_cart_key_has_no_items(): void
{
// Given: 존재하지 않는 cart_key (아이템 없음)
$user = User::factory()->create();
$nonExistentCartKey = 'ck_nonexistent123456789012345678';
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($nonExistentCartKey)
->once()
->andReturn(new Collection);
// When: 해당 cart_key로 병합 시도
$result = $this->service->mergeGuestCartToUser($nonExistentCartKey, $user->id);
// Then: 병합된 아이템 0개, 기존 회원 장바구니 유지
$this->assertEquals(0, $result);
}
// ========================================
// Section 7.14: 재고 검증 테스트 (6개)
// ========================================
/**
* 테스트 #83: 재고 초과 수량 담기 시도 → 예외
*
* 재고가 5개인 옵션에 10개를 담으려 하면 stock_exceeded 예외 발생
*/
public function test_add_to_cart_throws_exception_when_stock_exceeded(): void
{
// Given: 재고 5개인 옵션
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 5,
]);
// 기존 장바구니 없음
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection);
// Mock stock validation - 재고 5개 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
// Then: CartUnavailableException(reason stock) — 컨트롤러에서 422 로 매핑됨 (MP07 §1-b)
try {
$this->callAddToCart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 10, // 재고 5개 < 요청 10개
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$this->assertStringContainsString(
__('sirsoft-ecommerce::exceptions.stock_exceeded', ['available' => 5, 'requested' => 10]),
$e->getUserMessage()
);
}
}
/**
* 테스트 #87: 합산 후 재고 초과 → 예외
*
* 이미 장바구니에 3개 있고, 재고가 5개인 상태에서 5개 추가 시도 → 예외
* (3 + 5 = 8 > 5)
*/
public function test_add_to_cart_throws_exception_when_combined_quantity_exceeds_stock(): void
{
// Given: 재고 5개인 옵션, 기존 장바구니에 3개
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 5,
]);
$existingCart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 3,
]);
// 기존 장바구니 있음
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection([$existingCart]));
// Mock stock validation - 재고 5개 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
// Then: CartUnavailableException(reason stock) — 합산 8개 > 재고 5개 (MP07 §1-b)
try {
$this->callAddToCart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 5,
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$this->assertStringContainsString(
__('sirsoft-ecommerce::exceptions.stock_exceeded', ['available' => 5, 'requested' => 8]),
$e->getUserMessage()
);
}
}
/**
* 테스트 #88: 품절 상품 담기 시도 → 예외
*
* 재고가 0인 옵션을 담으려 하면 out_of_stock 예외 발생
*/
public function test_add_to_cart_throws_exception_when_out_of_stock(): void
{
// Given: 재고 0개인 옵션 (품절)
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 0,
]);
// 기존 장바구니 없음
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection);
// Mock stock validation - 재고 0개 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
// Then: CartUnavailableException(reason stock) — 품절(재고0)도 stock 사유 (MP07 §1-b)
try {
$this->callAddToCart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$items = $e->getUnavailableItems();
$this->assertSame('stock', $items[0]['reason'] ?? null);
$this->assertSame(0, $items[0]['stock'] ?? null);
}
}
/**
* 테스트 #91: 재고 초과 수량 변경 시도 → 예외
*
* 재고 5개인 옵션의 수량을 10개로 변경 시도 → 예외
*/
public function test_update_quantity_throws_exception_when_stock_exceeded(): void
{
// Given: 재고 5개인 옵션
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 5,
]);
$cart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 2,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock stock validation - 재고 5개 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->once()
->andReturn($option);
// Then: CartUnavailableException(reason stock) — 수량변경 재고초과 (MP07 §1-b)
try {
$this->service->updateQuantity($cart->id, 10, $user->id, null);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$this->assertStringContainsString(
__('sirsoft-ecommerce::exceptions.stock_exceeded', ['available' => 5, 'requested' => 10]),
$e->getUserMessage()
);
}
}
/**
* 테스트 #95: 옵션 변경 후 재고 초과 → 예외
*
* 새 옵션의 재고가 5개인데 기존 수량이 7개 → 예외
*/
public function test_change_option_throws_exception_when_stock_exceeded(): void
{
// Given: 기존 옵션으로 7개 담김
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$oldOption = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 10,
]);
$newOption = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 5,
]);
$cart = CartFactory::new()->forUser($user)->forOption($oldOption)->create([
'quantity' => 7,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock validateSameProduct and validateStock - newOption 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($newOption->id)
->andReturn($newOption);
// 새 옵션으로 기존에 담긴 것 없음
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $newOption->id)
->once()
->andReturn(new Collection);
// Then: CartUnavailableException(reason stock) — 옵션변경 재고초과 (MP07 §1-b)
try {
$this->service->changeOption($cart->id, $newOption->id, $cart->quantity, $user->id, null);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$this->assertStringContainsString(
__('sirsoft-ecommerce::exceptions.stock_exceeded', ['available' => 5, 'requested' => 7]),
$e->getUserMessage()
);
}
}
/**
* 테스트 #96: 다른 상품 옵션으로 변경 시도 → 예외
*
* 상품A의 옵션을 상품B의 옵션으로 변경 시도 → 예외
*/
public function test_change_option_throws_exception_for_different_product(): void
{
// Given: 상품A의 장바구니
$user = User::factory()->create();
$productA = ProductFactory::new()->create();
$optionA = ProductOptionFactory::new()->forProduct($productA)->create(['stock_quantity' => 100]);
// 상품B의 옵션
$productB = ProductFactory::new()->create();
$optionB = ProductOptionFactory::new()->forProduct($productB)->create(['stock_quantity' => 100]);
$cart = CartFactory::new()->forUser($user)->forOption($optionA)->create([
'product_id' => $productA->id,
]);
$this->mockCartRepository
->shouldReceive('find')
->with($cart->id)
->once()
->andReturn($cart);
// Mock validateSameProduct - 다른 상품의 옵션 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($optionB->id)
->once()
->andReturn($optionB);
// Then: 예외 발생 예상
$this->expectException(\Exception::class);
$this->expectExceptionMessage(__('sirsoft-ecommerce::exceptions.invalid_option_for_product'));
// When: 다른 상품 옵션으로 변경 시도
$this->service->changeOption($cart->id, $optionB->id, 1, $user->id, null);
}
// ========================================
// Section 7.15: 장바구니 병합 재고 테스트 (1개)
// ========================================
/**
* 테스트 #107: 병합 시 재고 초과 처리
*
* 비회원 장바구니 3개 + 회원 장바구니 4개 = 7개
* 재고가 5개면 회원 장바구니 수량을 5개로 제한
*/
public function test_merge_guest_cart_adjusts_quantity_when_stock_exceeded(): void
{
// Given: 재고 5개인 옵션
$user = User::factory()->create();
$product = ProductFactory::new()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 5,
]);
// 비회원 장바구니: 3개
$cartKey = 'ck_test1234567890123456789012345';
$guestCart = CartFactory::new()->forOption($option)->create([
'cart_key' => $cartKey,
'user_id' => null,
'quantity' => 3,
]);
// 회원 장바구니: 4개
$userCart = CartFactory::new()->forUser($user)->forOption($option)->create([
'quantity' => 4,
]);
$this->mockCartRepository
->shouldReceive('findByCartKeyWithoutUser')
->with($cartKey)
->once()
->andReturn(new Collection([$guestCart]));
// Mock stock check in mergeGuestCartToUser - 재고 5개 반환
$this->mockProductOptionRepository
->shouldReceive('findById')
->with($option->id)
->andReturn($option);
// 회원 장바구니에 같은 옵션 있음
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->with($user->id, $option->id)
->once()
->andReturn(new Collection([$userCart]));
// 기대: 수량을 재고(5개)로 조정하여 업데이트
$updatedUserCart = clone $userCart;
$updatedUserCart->quantity = 5; // 재고 한도
$this->mockCartRepository
->shouldReceive('update')
->withArgs(function ($cart, $data) use ($userCart) {
return $cart->id === $userCart->id && $data['quantity'] === 5;
})
->once()
->andReturn($updatedUserCart);
// 비회원 장바구니 삭제
$this->mockCartRepository
->shouldReceive('delete')
->with($guestCart)
->once()
->andReturn(true);
// When
$result = $this->service->mergeGuestCartToUser($cartKey, $user->id);
// Then: 1개 아이템 처리됨 (병합)
$this->assertEquals(1, $result);
}
// ========================================
// Section 7.17: 전체 삭제 테스트
// ========================================
/**
* 테스트 #99-1: 회원 장바구니 전체 삭제
*
* 회원의 장바구니를 전체 삭제하면 해당 회원의 모든 아이템이 삭제됩니다.
*/
public function test_delete_all_deletes_all_items_for_user(): void
{
// Given
$user = User::factory()->create();
$this->mockCartRepository
->shouldReceive('deleteByUserId')
->with($user->id)
->once()
->andReturn(5);
// When
$result = $this->service->deleteAll($user->id, null);
// Then
$this->assertEquals(5, $result);
}
/**
* 테스트 #99-2: 비회원 장바구니 전체 삭제
*
* 비회원의 장바구니를 전체 삭제하면 해당 cart_key의 모든 아이템이 삭제됩니다.
*/
public function test_delete_all_deletes_all_items_for_guest(): void
{
// Given
$cartKey = 'ck_test123456789012345678901234';
$this->mockCartRepository
->shouldReceive('deleteByCartKey')
->with($cartKey)
->once()
->andReturn(3);
// When
$result = $this->service->deleteAll(null, $cartKey);
// Then
$this->assertEquals(3, $result);
}
/**
* 테스트 #99-3: 인증정보 없이 전체 삭제
*
* userId와 cartKey 모두 없으면 0을 반환합니다.
*/
public function test_delete_all_returns_zero_without_credentials(): void
{
// When
$result = $this->service->deleteAll(null, null);
// Then
$this->assertEquals(0, $result);
}
/**
* 테스트 #99-4: 빈 장바구니 전체 삭제
*
* 장바구니가 비어있을 때 전체 삭제 호출 시 0을 반환합니다.
*/
public function test_delete_all_returns_zero_for_empty_cart(): void
{
// Given
$user = User::factory()->create();
$this->mockCartRepository
->shouldReceive('deleteByUserId')
->with($user->id)
->once()
->andReturn(0);
// When
$result = $this->service->deleteAll($user->id, null);
// Then
$this->assertEquals(0, $result);
}
// ========================================
// U13②/U4: 판매상태 담기 차단 테스트
// ========================================
/**
* 판매중지 상품을 담으려 하면 CartUnavailableException(status) 이 발생합니다.
*/
#[DataProvider('unavailableSalesStateProvider')]
public function test_add_to_cart_blocks_unavailable_product(string $factoryState): void
{
// Given: 비정상 판매상태 상품 + 옵션
$user = User::factory()->create();
$product = ProductFactory::new()->{$factoryState}()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->andReturn(new Collection);
// When / Then: 판매상태 차단 (reason status)
try {
$this->callAddToCart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStatusIssue());
}
}
/**
* 판매중(on_sale)+전시중(visible) 상품은 정상적으로 담깁니다 (회귀 가드).
*/
public function test_add_to_cart_allows_purchasable_product(): void
{
// Given
$user = User::factory()->create();
$product = ProductFactory::new()->onSale()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 100]);
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->andReturn(new Collection);
$newCart = new Cart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
]);
$newCart->id = 1;
$this->mockCartRepository
->shouldReceive('create')
->once()
->andReturn($newCart);
// When
$result = $this->callAddToCart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 1,
]);
// Then
$this->assertEquals($option->id, $result->product_option_id);
}
/**
* 판매중지/품절/출시예정/전시중지 상태 데이터 제공자.
*
* @return array<string, array{string}>
*/
public static function unavailableSalesStateProvider(): array
{
return [
'suspended' => ['suspended'],
'sold_out' => ['soldOut'],
'coming_soon' => ['comingSoon'],
'hidden' => ['hidden'],
];
}
// ========================================
// A25: 구매수량 한도 담기 차단 테스트
// ========================================
/**
* 최소 구매수량 미만으로 담으면 CartUnavailableException(min_qty) 이 발생합니다.
*/
public function test_bulk_add_blocks_below_min_purchase_qty(): void
{
// Given: 최소 3개 상품
$user = User::factory()->create();
$product = ProductFactory::new()->onSale()->create([
'min_purchase_qty' => 3,
'max_purchase_qty' => 0,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 100,
'is_default' => true,
]);
$this->mockProductOptionRepository
->shouldReceive('getByProductId')
->with($product->id)
->andReturn(new Collection([$option]));
// When / Then: 1개 담기 시도 → min_qty 차단
try {
$this->service->bulkAddToCart([
'product_id' => $product->id,
'user_id' => $user->id,
'cart_key' => null,
'items' => [['quantity' => 1]],
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasMinQtyIssue());
}
}
/**
* 최대 구매수량 초과(기존 장바구니 + 신규 합산)로 담으면 max_qty 차단됩니다.
*/
public function test_bulk_add_blocks_above_max_purchase_qty_with_existing_cart(): void
{
// Given: 최대 5개 상품, 기존 장바구니 4개 보유
$user = User::factory()->create();
$product = ProductFactory::new()->onSale()->create([
'min_purchase_qty' => 1,
'max_purchase_qty' => 5,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 100,
'is_default' => true,
]);
$this->mockProductOptionRepository
->shouldReceive('getByProductId')
->with($product->id)
->andReturn(new Collection([$option]));
// 기존 장바구니 4개 (자기 product)
$this->mockCartRepository
->shouldReceive('sumQuantityByProduct')
->with($product->id, $user->id, null)
->andReturn(4);
// When / Then: 신규 3개 → 합산 7 > 5 차단
try {
$this->service->bulkAddToCart([
'product_id' => $product->id,
'user_id' => $user->id,
'cart_key' => null,
'items' => [['quantity' => 3]],
]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasMaxQtyIssue());
}
}
/**
* max_purchase_qty = 0 은 무제한 — 대량 담기도 통과합니다 (경계).
*/
public function test_bulk_add_allows_unlimited_when_max_qty_zero(): void
{
// Given: max=0(무제한), min=1
$user = User::factory()->create();
$product = ProductFactory::new()->onSale()->create([
'min_purchase_qty' => 1,
'max_purchase_qty' => 0,
]);
$option = ProductOptionFactory::new()->forProduct($product)->create([
'stock_quantity' => 1000,
'is_default' => true,
]);
$this->mockProductOptionRepository
->shouldReceive('getByProductId')
->with($product->id)
->andReturn(new Collection([$option]));
$this->mockCartRepository
->shouldReceive('findAllByUserAndOption')
->andReturn(new Collection);
$newCart = new Cart([
'user_id' => $user->id,
'product_id' => $product->id,
'product_option_id' => $option->id,
'quantity' => 50,
]);
$newCart->id = 1;
$this->mockCartRepository
->shouldReceive('create')
->andReturn($newCart);
$this->mockCartRepository
->shouldReceive('countItems')
->andReturn(1);
// When: 50개 담기
$result = $this->service->bulkAddToCart([
'product_id' => $product->id,
'user_id' => $user->id,
'cart_key' => null,
'items' => [['quantity' => 50]],
]);
// Then: 통과
$this->assertCount(1, $result['items']);
}
// ========================================
// validateStock() — 재고 예외 도메인화 (MP07 §1-b, U9/U8)
// 품절(재고0)·재고초과는 generic \Exception(→500) 이 아닌
// CartUnavailableException(reason: stock, →422) 이어야 한다.
// ========================================
/**
* 재고 0 옵션 검증 → CartUnavailableException(reason stock) (수정 전엔 generic \Exception → 500).
*/
public function test_validate_stock_out_of_stock_throws_cart_unavailable_stock(): void
{
$product = ProductFactory::new()->onSale()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 0]);
try {
$this->invokePrivateMethod($this->service, 'validateStock', [$option->id, 1, 0]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue(), 'reason=stock 이어야 합니다.');
$items = $e->getUnavailableItems();
$this->assertSame('stock', $items[0]['reason'] ?? null);
$this->assertSame($option->id, $items[0]['product_option_id'] ?? null);
}
}
/**
* 요청 수량 > 재고 → CartUnavailableException(reason stock) + 치환 메타(quantity/stock).
*/
public function test_validate_stock_exceeding_throws_cart_unavailable_with_meta(): void
{
$product = ProductFactory::new()->onSale()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 3]);
try {
// 현재 2 + 요청 5 = 7 > 재고 3
$this->invokePrivateMethod($this->service, 'validateStock', [$option->id, 5, 2]);
$this->fail('CartUnavailableException 이 발생해야 합니다.');
} catch (CartUnavailableException $e) {
$this->assertTrue($e->hasStockIssue());
$items = $e->getUnavailableItems();
// getUserMessage 의 stock 분기가 quantity(요청합산)/stock(가용)을 치환에 사용
$this->assertSame(7, $items[0]['quantity'] ?? null);
$this->assertSame(3, $items[0]['stock'] ?? null);
}
}
/**
* 정상 재고 → 예외 없음 (회귀 보호).
*/
public function test_validate_stock_within_stock_does_not_throw(): void
{
$product = ProductFactory::new()->onSale()->create();
$option = ProductOptionFactory::new()->forProduct($product)->create(['stock_quantity' => 10]);
// 예외 없이 통과해야 함
$this->invokePrivateMethod($this->service, 'validateStock', [$option->id, 5, 0]);
$this->assertTrue(true);
}
}