laravel-shop/tests/Feature/CartItemAvailabilityValidat...

391 lines
14 KiB
PHP

<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Workbench\App\Models\User;
/**
* Tests for cart item validation when dates change and items become unavailable.
*
* Bug: When adjusting dates in cart, some cart items show null/0 price because they
* are not available for the new dates. But IsReadyToCheckout incorrectly returns true.
*
* Expected behavior:
* - setDates() should NOT throw - it should allow users to fiddle with dates
* - Items that become unavailable should have price = null
* - Items with null price should NOT be ready for checkout
* - Cart.isReadyForCheckout() should return false if any items are unavailable
* - Exception should only be thrown at checkout time, not when changing dates
*/
class CartItemAvailabilityValidationTest extends TestCase
{
protected User $user;
protected Cart $cart;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
auth()->login($this->user);
$this->cart = Cart::factory()->create([
'customer_id' => $this->user->id,
'customer_type' => get_class($this->user),
]);
}
/**
* Create a pool with limited singles for testing
*/
protected function createPoolWithLimitedSingles(int $numSingles = 3): Product
{
$pool = Product::factory()->create([
'name' => 'Limited Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
ProductPrice::factory()->create([
'purchasable_id' => $pool->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->setPoolPricingStrategy('lowest');
// Create singles with 1 stock each
for ($i = 1; $i <= $numSingles; $i++) {
$single = Product::factory()->create([
'name' => "Single {$i}",
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$single->increaseStock(1);
ProductPrice::factory()->create([
'purchasable_id' => $single->id,
'purchasable_type' => Product::class,
'unit_amount' => 5000,
'currency' => 'USD',
'is_default' => true,
]);
$pool->attachSingleItems([$single->id]);
}
return $pool;
}
/** @test */
public function cart_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to null to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertNull($item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with null-price item should not be ready');
}
/** @test */
public function cart_item_with_zero_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually set one item's price to 0 to simulate unavailable item
$item = $this->cart->items()->first();
$item->update(['price' => 0, 'subtotal' => 0]);
$item->refresh();
// Item with 0 price should NOT be ready for checkout
$this->assertEquals(0, $item->price);
$this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with 0-price item should not be ready');
}
/** @test */
public function unallocated_pool_item_with_null_price_is_not_ready_for_checkout()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates - all should be allocated
$this->cart->addToCart($pool, 3, [], $from, $until);
// Manually simulate an item becoming unavailable:
// - Remove allocation
// - Set price to null (the real indicator of unavailability)
$item = $this->cart->items()->first();
$meta = $item->getMeta();
unset($meta->allocated_single_item_id);
unset($meta->allocated_single_item_name);
$item->update([
'meta' => json_encode($meta),
'price' => null,
'subtotal' => null,
]);
$item->refresh();
// Item with null price should NOT be ready for checkout
$this->assertFalse($item->is_ready_to_checkout, 'Item with null price should not be ready for checkout');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout, 'Cart with unavailable item should not be ready');
}
/** @test */
public function setDates_does_not_throw_when_items_become_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims the stock
// Our user adds items without dates (should work - we have 3 total capacity)
$this->cart->addToCart($pool, 3);
// All items should have prices > 0 initially
foreach ($this->cart->items as $item) {
$this->assertGreaterThan(0, $item->price, 'Item should have positive price initially');
}
// Now set dates that conflict with the booked period
// This should NOT throw - it should just mark items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Cart should NOT be ready for checkout (items are unavailable)
$this->assertFalse(
$this->cart->is_ready_to_checkout,
'Cart should not be ready when items are unavailable for selected dates'
);
}
/** @test */
public function partial_availability_marks_some_items_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books 2 of 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 2, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout(); // Claims 2 singles
// Verify that only 1 single is available for the booked period
$available = $pool->getPoolMaxQuantity($bookedFrom, $bookedUntil);
$this->assertEquals(1, $available, 'Only 1 single should be available after booking 2');
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
$this->assertEquals(3, $this->cart->items()->sum('quantity'));
// Set dates where only 1 single is available
// Should NOT throw - just mark some items as unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->cart->refresh();
$this->cart->load('items');
// Check how many items are available vs unavailable
$availableItems = $this->cart->items->filter(
fn($item) =>
$item->price !== null && $item->price > 0
);
$unavailableItems = $this->cart->items->filter(
fn($item) =>
$item->price === null || $item->price <= 0
);
// Should have 1 available and 2 unavailable
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item');
$this->assertEquals(2, $unavailableItems->count(), 'Should have 2 unavailable items');
// Cart should NOT be ready for checkout
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart with unavailable items should not be ready');
}
/** @test */
public function cart_item_without_allocated_single_for_pool_is_not_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Verify all items are allocated and ready
foreach ($this->cart->items as $item) {
$meta = $item->getMeta();
$this->assertNotNull($meta->allocated_single_item_id ?? null, 'Item should be allocated');
$this->assertTrue($item->is_ready_to_checkout, 'Allocated item should be ready');
}
// All items ready - cart is ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
/** @test */
public function removing_unavailable_items_makes_cart_ready()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add 3 items without dates
$this->cart->addToCart($pool, 3);
// Manually make one item unavailable (price = null)
$unavailableItem = $this->cart->items()->first();
$unavailableItem->update(['price' => null, 'subtotal' => null]);
// Cart should NOT be ready
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Remove the unavailable item
$unavailableItem->delete();
// Set dates for remaining items
$from = now()->addDays(1);
$until = now()->addDays(2);
$this->cart->setDates($from, $until);
// Now cart should be ready
$this->assertTrue($this->cart->fresh()->is_ready_to_checkout);
}
/** @test */
public function getItemsRequiringAdjustments_includes_null_price_items()
{
$pool = $this->createPoolWithLimitedSingles(3);
$from = now()->addDays(1);
$until = now()->addDays(2);
// Add 3 items with dates
$this->cart->addToCart($pool, 3, [], $from, $until);
// Make one item have null price
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
$this->cart->refresh();
$this->cart->load('items');
// Get items requiring adjustments
$itemsNeedingAdjustment = $this->cart->getItemsRequiringAdjustments();
// The null-price item should be in the list
$this->assertGreaterThanOrEqual(
1,
$itemsNeedingAdjustment->count(),
'Null price item should require adjustment'
);
// Check that it has 'unavailable' as the price adjustment reason
$nullPriceItem = $itemsNeedingAdjustment->first(fn($i) => $i->price === null);
$this->assertNotNull($nullPriceItem, 'Should find the null-price item');
$adjustments = $nullPriceItem->requiredAdjustments();
$this->assertArrayHasKey('price', $adjustments);
$this->assertEquals('unavailable', $adjustments['price']);
}
/** @test */
public function changing_dates_to_available_period_makes_items_available_again()
{
$pool = $this->createPoolWithLimitedSingles(3);
// First user books all 3 singles for specific dates
$user1 = User::factory()->create();
$user1Cart = $user1->currentCart();
$bookedFrom = now()->addDays(5);
$bookedUntil = now()->addDays(6);
$user1Cart->addToCart($pool, 3, [], $bookedFrom, $bookedUntil);
$user1Cart->checkout();
// Our user adds 3 items without dates
$this->cart->addToCart($pool, 3);
// Set dates that conflict - items become unavailable
$this->cart->setDates($bookedFrom, $bookedUntil);
$this->assertFalse($this->cart->fresh()->is_ready_to_checkout);
// Change to different dates where all singles are available
$availableFrom = now()->addDays(10);
$availableUntil = now()->addDays(11);
$this->cart->setDates($availableFrom, $availableUntil);
$this->cart->refresh();
$this->cart->load('items');
// All items should now have valid prices
foreach ($this->cart->items as $item) {
$this->assertNotNull($item->price, 'Item should have price after changing to available dates');
$this->assertGreaterThan(0, $item->price, 'Item should have positive price');
}
// Cart should be ready for checkout
$this->assertTrue($this->cart->is_ready_to_checkout, 'Cart should be ready after changing to available dates');
}
/** @test */
public function checkout_throws_when_items_are_unavailable()
{
$pool = $this->createPoolWithLimitedSingles(3);
// Add items and make one unavailable
$this->cart->addToCart($pool, 3);
$item = $this->cart->items()->first();
$item->update(['price' => null, 'subtotal' => null]);
// Trying to checkout should throw CartItemMissingInformationException
// because the item has 'price' => 'unavailable' in requiredAdjustments()
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
$this->cart->checkout();
}
}