BFI pool cart
This commit is contained in:
parent
20e6538626
commit
0e6b420297
|
|
@ -284,10 +284,10 @@ return new class extends Migration
|
|||
$table->foreignUuid('purchase_id')->nullable()->constrained(config('shop.tables.product_purchases', 'product_purchases'))->nullOnDelete();
|
||||
$table->foreignUuid('price_id')->nullable()->constrained(config('shop.tables.product_prices', 'product_prices'))->nullOnDelete();
|
||||
$table->integer('quantity')->default(1);
|
||||
$table->integer('price')->default(0); // Stored in cents
|
||||
$table->integer('price')->nullable(); // Stored in cents, null = unavailable
|
||||
$table->integer('regular_price')->nullable(); // Stored in cents
|
||||
$table->integer('unit_amount')->nullable(); // Base unit price for 1 quantity, 1 day (in cents)
|
||||
$table->integer('subtotal'); // Stored in cents
|
||||
$table->integer('subtotal')->nullable(); // Stored in cents, null = unavailable
|
||||
$table->json('parameters')->nullable();
|
||||
$table->json('meta')->nullable();
|
||||
$table->timestamp('from')->nullable();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class CartNotReadyException extends Exception
|
||||
{
|
||||
public function __construct(string $message = "Cart is not ready for checkout. Some items may be unavailable.")
|
||||
{
|
||||
parent::__construct($message);
|
||||
}
|
||||
}
|
||||
|
|
@ -408,8 +408,22 @@ class Cart extends Model
|
|||
if ($validateAvailability) {
|
||||
$product = $item->purchasable;
|
||||
|
||||
// For pool products, track allocation for total validation
|
||||
// For pool products, check if allocated by reallocatePoolItems
|
||||
if ($product instanceof Product && $product->isPool()) {
|
||||
$meta = $item->getMeta();
|
||||
$allocatedSingleItemId = $meta->allocated_single_item_id ?? null;
|
||||
|
||||
// If this item was NOT allocated (no single assigned), skip updateDates
|
||||
// to preserve the null price set by reallocatePoolItems
|
||||
if (empty($allocatedSingleItemId)) {
|
||||
// Just update the dates without recalculating price
|
||||
$item->update([
|
||||
'from' => $itemFrom,
|
||||
'until' => $itemUntil,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
|
||||
$poolKey = $product->id . '|' . $itemFrom->format('Y-m-d H:i:s') . '|' . $itemUntil->format('Y-m-d H:i:s');
|
||||
|
||||
if (!isset($poolValidation[$poolKey])) {
|
||||
|
|
@ -423,19 +437,19 @@ class Cart extends Model
|
|||
}
|
||||
|
||||
$poolValidation[$poolKey]['requested'] += $item->quantity;
|
||||
|
||||
$meta = $item->getMeta();
|
||||
if (isset($meta->allocated_single_item_id)) {
|
||||
$poolValidation[$poolKey]['allocated'] += $item->quantity;
|
||||
}
|
||||
$poolValidation[$poolKey]['allocated'] += $item->quantity;
|
||||
} elseif ($product && !$product->isAvailableForBooking($itemFrom, $itemUntil, $item->quantity)) {
|
||||
throw new NotEnoughAvailableInTimespanException(
|
||||
productName: $product->name ?? 'Product',
|
||||
requested: $item->quantity,
|
||||
available: 0,
|
||||
from: $itemFrom,
|
||||
until: $itemUntil
|
||||
);
|
||||
// Non-pool booking item is not available - mark as unavailable
|
||||
// Don't throw exception - let user adjust dates freely
|
||||
$item->update([
|
||||
'from' => $itemFrom,
|
||||
'until' => $itemUntil,
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
// Skip updateDates() since we already set the dates with null price
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -443,21 +457,10 @@ class Cart extends Model
|
|||
}
|
||||
}
|
||||
|
||||
// Validate pool allocations - all requested items must be allocated
|
||||
if ($validateAvailability) {
|
||||
foreach ($poolValidation as $poolData) {
|
||||
if ($poolData['requested'] > $poolData['allocated']) {
|
||||
$product = $poolData['product'];
|
||||
throw new NotEnoughAvailableInTimespanException(
|
||||
productName: $product->name ?? 'Product',
|
||||
requested: $poolData['requested'],
|
||||
available: $poolData['allocated'],
|
||||
from: $poolData['from'],
|
||||
until: $poolData['until']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pool validation is now handled by reallocatePoolItems() which marks
|
||||
// unallocated items with null price instead of throwing exceptions.
|
||||
// This allows users to freely adjust dates without exceptions.
|
||||
// Validation happens at checkout time via isReadyForCheckout().
|
||||
|
||||
return $this->fresh();
|
||||
}
|
||||
|
|
@ -547,6 +550,22 @@ class Cart extends Model
|
|||
}
|
||||
|
||||
if (empty($availableWithPrices)) {
|
||||
// No singles available for this period - mark ALL pool items as unavailable
|
||||
foreach ($items as $cartItem) {
|
||||
// Only update if we should overwrite or item has no dates yet
|
||||
if (!$overwrite && $cartItem->from && $cartItem->until) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Clear allocation and set price to null to indicate unavailable
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
$cartItem->update([
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -589,9 +608,16 @@ class Cart extends Model
|
|||
}
|
||||
}
|
||||
|
||||
// If we couldn't allocate (ran out of available singles), stop
|
||||
// If we couldn't allocate (ran out of available singles), mark as unavailable
|
||||
if (!$allocated) {
|
||||
break;
|
||||
// Clear allocation and set price to null to indicate unavailable
|
||||
$cartItem->updateMetaKey('allocated_single_item_id', null);
|
||||
$cartItem->updateMetaKey('allocated_single_item_name', null);
|
||||
$cartItem->update([
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -605,6 +631,15 @@ class Cart extends Model
|
|||
* @return void
|
||||
* @throws NotEnoughAvailableInTimespanException
|
||||
*/
|
||||
/**
|
||||
* Mark booking items as unavailable if they cannot be booked for the given dates.
|
||||
* Instead of throwing exceptions, this marks items with null price.
|
||||
*
|
||||
* @param \DateTimeInterface $from Start date
|
||||
* @param \DateTimeInterface $until End date
|
||||
* @param bool $useProvidedDates Whether to use provided dates or item's own dates
|
||||
* @return void
|
||||
*/
|
||||
protected function validateDateAvailability(\DateTimeInterface $from, \DateTimeInterface $until, bool $useProvidedDates = false): void
|
||||
{
|
||||
foreach ($this->items as $item) {
|
||||
|
|
@ -617,18 +652,23 @@ class Cart extends Model
|
|||
continue;
|
||||
}
|
||||
|
||||
// Skip pool products - they are handled by reallocatePoolItems()
|
||||
if ($product->type === ProductType::POOL) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Use provided dates when validating date overwrites, otherwise use item's specific dates
|
||||
$checkFrom = $useProvidedDates ? $from : ($item->from ?? $from);
|
||||
$checkUntil = $useProvidedDates ? $until : ($item->until ?? $until);
|
||||
|
||||
if (!$product->isAvailableForBooking($checkFrom, $checkUntil, $item->quantity)) {
|
||||
throw new NotEnoughAvailableInTimespanException(
|
||||
productName: $product->name ?? 'Product',
|
||||
requested: $item->quantity,
|
||||
available: 0, // Could calculate actual available amount
|
||||
from: $checkFrom,
|
||||
until: $checkUntil
|
||||
);
|
||||
// Mark item as unavailable instead of throwing exception
|
||||
// This allows users to freely adjust dates
|
||||
$item->update([
|
||||
'price' => null,
|
||||
'subtotal' => null,
|
||||
'unit_amount' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -768,9 +808,25 @@ class Cart extends Model
|
|||
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available for the requested period. Requested: {$quantity}"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// When dates are not provided, validate against total pool capacity (not current availability)
|
||||
// This allows adding items even if currently claimed - dates will be validated later
|
||||
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
|
||||
|
||||
// Subtract items already in cart
|
||||
$itemsInCart = $this->items()
|
||||
->where('purchasable_id', $cartable->getKey())
|
||||
->where('purchasable_type', get_class($cartable))
|
||||
->sum('quantity');
|
||||
|
||||
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
|
||||
|
||||
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
|
||||
throw new NotEnoughStockException(
|
||||
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// When dates are not provided, skip availability validation - allow flexible cart behavior
|
||||
// The cart will validate when dates are set via setDates()
|
||||
|
||||
// Add items one at a time for progressive pricing
|
||||
$lastCartItem = null;
|
||||
|
|
@ -831,13 +887,27 @@ class Cart extends Model
|
|||
// If only one date is provided, it's an error
|
||||
throw new CartDatesRequiredException();
|
||||
} else {
|
||||
// When adding pool items without dates, allow adding even if currently unavailable
|
||||
// Items may be claimed now but available in the future
|
||||
// Validation will happen when dates are set or at checkout
|
||||
// This enables flexible booking workflows where users add items first, then select dates
|
||||
// When adding pool items without dates, validate against total pool capacity
|
||||
// This allows adding items even if currently claimed - date-based validation happens later
|
||||
if ($cartable->isPool()) {
|
||||
$totalCapacity = $cartable->getPoolTotalCapacity(); // Total capacity ignoring claims
|
||||
|
||||
// Note: We skip availability validation here for pool products without dates
|
||||
// The cart will not be ready for checkout without dates anyway
|
||||
// Subtract items already in cart (without dates or with any dates)
|
||||
$itemsInCart = $this->items()
|
||||
->where('purchasable_id', $cartable->getKey())
|
||||
->where('purchasable_type', get_class($cartable))
|
||||
->sum('quantity');
|
||||
|
||||
$availableForThisRequest = $totalCapacity === PHP_INT_MAX ? PHP_INT_MAX : max(0, $totalCapacity - $itemsInCart);
|
||||
|
||||
if ($availableForThisRequest !== PHP_INT_MAX && $quantity > $availableForThisRequest) {
|
||||
throw new NotEnoughStockException(
|
||||
"Pool product '{$cartable->name}' has only {$availableForThisRequest} items available. Requested: {$quantity}"
|
||||
);
|
||||
}
|
||||
}
|
||||
// Items may be claimed now but available in the future
|
||||
// Full date-based validation will happen when dates are set via setDates() or at checkout
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1518,9 +1588,9 @@ class Cart extends Model
|
|||
*/
|
||||
public function checkoutSessionLink(array $option = [], ?string $url = null): string|null|false
|
||||
{
|
||||
if (! @$this->validateForCheckout(false)) {
|
||||
return null;
|
||||
}
|
||||
// Validate cart - throw exceptions if validation fails
|
||||
// This ensures users know what's wrong instead of silently returning null
|
||||
$this->validateForCheckout();
|
||||
|
||||
$checkoutSession = $this->checkoutSession($option, $url);
|
||||
|
||||
|
|
|
|||
|
|
@ -180,6 +180,15 @@ class CartItem extends Model
|
|||
return false;
|
||||
}
|
||||
|
||||
// Check if item has a valid price (null or <= 0 means unavailable)
|
||||
if ($this->price === null || $this->price <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Note: Pool items don't require pre-allocation to be ready for checkout.
|
||||
// The checkout process can allocate singles on-the-fly via claimPoolStock().
|
||||
// The price check above is sufficient - if price is null, item is unavailable.
|
||||
|
||||
// Check if dates are required (for booking products or pools with booking items)
|
||||
$requiresDates = $product->isBooking() ||
|
||||
($product->isPool() && $product->hasBookingSingleItems());
|
||||
|
|
@ -323,6 +332,15 @@ class CartItem extends Model
|
|||
return $adjustments;
|
||||
}
|
||||
|
||||
// Check if price is invalid (null, zero or negative means unavailable)
|
||||
if ($this->price === null || $this->price <= 0) {
|
||||
$adjustments['price'] = 'unavailable';
|
||||
}
|
||||
|
||||
// Note: Pool items don't require pre-allocation to be ready for checkout.
|
||||
// The checkout process can allocate singles on-the-fly via claimPoolStock().
|
||||
// The price check above is sufficient - if price is null, item is unavailable.
|
||||
|
||||
// Check if dates are required (for booking products or pools with booking items)
|
||||
$requiresDates = $product->isBooking() ||
|
||||
($product->isPool() && $product->hasBookingSingleItems());
|
||||
|
|
|
|||
|
|
@ -112,6 +112,55 @@ trait MayBePoolProduct
|
|||
return $availableCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the total capacity of the pool (sum of all single item stock quantities)
|
||||
*
|
||||
* Unlike getPoolMaxQuantity(), this method returns the TOTAL capacity regardless
|
||||
* of current claims or availability. This is useful for validating cart additions
|
||||
* without dates - you can't add more items than the pool has single items, even
|
||||
* if you haven't chosen dates yet.
|
||||
*
|
||||
* @return int Total capacity (sum of single item stock quantities)
|
||||
*/
|
||||
public function getPoolTotalCapacity(): int
|
||||
{
|
||||
if (!$this->isPool()) {
|
||||
return $this->manage_stock ? ($this->stock_quantity ?? 0) : PHP_INT_MAX;
|
||||
}
|
||||
|
||||
$singleItems = $this->singleProducts;
|
||||
|
||||
if ($singleItems->isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$hasUnlimitedItem = false;
|
||||
$total = 0;
|
||||
|
||||
foreach ($singleItems as $item) {
|
||||
if (!$item->manage_stock) {
|
||||
$hasUnlimitedItem = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get total stock quantity (not available stock)
|
||||
// Sum all INCREASE entries to get the total capacity
|
||||
$itemCapacity = $item->stocks()
|
||||
->where('type', \Blax\Shop\Enums\StockType::INCREASE->value)
|
||||
->where('status', \Blax\Shop\Enums\StockStatus::COMPLETED->value)
|
||||
->sum('quantity');
|
||||
|
||||
$total += $itemCapacity;
|
||||
}
|
||||
|
||||
// If ALL items are unlimited, pool is unlimited
|
||||
if ($hasUnlimitedItem && $total === 0) {
|
||||
return PHP_INT_MAX;
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claim stock for a pool product
|
||||
* This will claim stock from the available single items, respecting the pricing strategy
|
||||
|
|
|
|||
|
|
@ -468,7 +468,7 @@ class CartDateManagementTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function validate_date_availability_throws_exception_when_product_not_available()
|
||||
public function validate_date_availability_marks_items_unavailable_when_product_not_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
|
|
@ -490,13 +490,17 @@ class CartDateManagementTest extends TestCase
|
|||
// Set item dates that consume the stock
|
||||
$item->updateDates(Carbon::now()->addDays(1), Carbon::now()->addDays(3));
|
||||
|
||||
// Try to set cart dates that overlap - should throw exception
|
||||
$this->expectException(NotEnoughAvailableInTimespanException::class);
|
||||
// Try to set cart dates that overlap - should NOT throw, instead mark items unavailable
|
||||
$cart->setDates(Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true);
|
||||
|
||||
// Item should now be marked as unavailable (null price)
|
||||
$item->refresh();
|
||||
$this->assertNull($item->price, 'Unavailable item should have null price');
|
||||
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function apply_dates_to_items_throws_exception_when_product_not_available()
|
||||
public function apply_dates_to_items_marks_items_unavailable_when_product_not_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'type' => ProductType::BOOKING,
|
||||
|
|
@ -520,9 +524,13 @@ class CartDateManagementTest extends TestCase
|
|||
// Add item that would exceed available stock
|
||||
$item = $cart->addToCart($product, 2);
|
||||
|
||||
// Should throw exception because only 1 available but requesting 2
|
||||
$this->expectException(NotEnoughAvailableInTimespanException::class);
|
||||
// Should NOT throw exception, instead mark items as unavailable
|
||||
$cart->applyDatesToItems(validateAvailability: true);
|
||||
|
||||
// Item should be marked as unavailable (null price)
|
||||
$item->refresh();
|
||||
$this->assertNull($item->price, 'Unavailable item should have null price');
|
||||
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
|
|
@ -0,0 +1,390 @@
|
|||
<?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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,250 @@
|
|||
<?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 to ensure pool products cannot exceed available single items
|
||||
* even when adding without dates (flexible cart behavior)
|
||||
*/
|
||||
class PoolMaxQuantityValidationTest 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 7 single items (production scenario)
|
||||
*/
|
||||
protected function createPoolWith7Singles(): Product
|
||||
{
|
||||
$pool = Product::factory()->create([
|
||||
'name' => 'Production 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 7 single items, each with 1 stock
|
||||
for ($i = 1; $i <= 7; $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 cannot_add_more_items_than_available_singles_without_dates()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
// Pool has 7 single items, each with 1 stock
|
||||
$this->assertEquals(7, $pool->getPoolMaxQuantity());
|
||||
|
||||
// Should be able to add 7 items
|
||||
$this->cart->addToCart($pool, 7);
|
||||
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Should NOT be able to add 8th item - should throw exception
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 0 items available');
|
||||
|
||||
$this->cart->addToCart($pool, 1);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_add_more_items_than_available_singles_with_dates()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
$from = now()->addDays(1);
|
||||
$until = now()->addDays(2);
|
||||
|
||||
// Pool has 7 single items, each with 1 stock
|
||||
$this->assertEquals(7, $pool->getPoolMaxQuantity($from, $until));
|
||||
|
||||
// Should be able to add 7 items with dates
|
||||
$this->cart->addToCart($pool, 7, [], $from, $until);
|
||||
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Should NOT be able to add 8th item - should throw exception
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 0 items available');
|
||||
|
||||
$this->cart->addToCart($pool, 1, [], $from, $until);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_add_batch_exceeding_available_singles_without_dates()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
// Trying to add 8 items at once should fail
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 7 items available');
|
||||
|
||||
$this->cart->addToCart($pool, 8);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_add_batch_exceeding_available_singles_with_dates()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
$from = now()->addDays(1);
|
||||
$until = now()->addDays(2);
|
||||
|
||||
// Trying to add 8 items at once should fail
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 7 items available');
|
||||
|
||||
$this->cart->addToCart($pool, 8, [], $from, $until);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function adding_items_without_dates_then_adding_more_validates_correctly()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
// Add 5 items without dates
|
||||
$this->cart->addToCart($pool, 5);
|
||||
$this->assertEquals(5, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Should be able to add 2 more (total 7)
|
||||
$this->cart->addToCart($pool, 2);
|
||||
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Should NOT be able to add 1 more
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->cart->addToCart($pool, 1);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkoutSessionLink_throws_exception_when_cart_invalid()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
// Add items without dates - cart is not ready for checkout
|
||||
$this->cart->addToCart($pool, 3);
|
||||
|
||||
// checkoutSessionLink should throw exception, not return null
|
||||
// When items don't have dates, validation throws CartItemMissingInformationException
|
||||
$this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class);
|
||||
$this->expectExceptionMessage('is missing required information: from, until');
|
||||
|
||||
$this->cart->checkoutSessionLink();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkoutSessionLink_throws_exception_when_not_enough_stock()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
$from = now()->addDays(1);
|
||||
$until = now()->addDays(2);
|
||||
|
||||
// Add 7 items with dates
|
||||
$this->cart->addToCart($pool, 7, [], $from, $until);
|
||||
|
||||
// Simulate another cart claiming all stock for the same period
|
||||
$otherCart = Cart::factory()->create([
|
||||
'customer_id' => User::factory()->create()->id,
|
||||
'customer_type' => User::class,
|
||||
]);
|
||||
$otherCart->addToCart($pool, 7, [], $from, $until);
|
||||
$otherCart->checkout(); // This claims the stock
|
||||
|
||||
// Our cart should now fail validation when trying to create checkout session
|
||||
// The validation throws NotEnoughStockException when checking availability
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 0 items available');
|
||||
|
||||
$this->cart->fresh()->checkoutSessionLink();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cart_aware_validation_accounts_for_items_already_in_cart()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
$from = now()->addDays(1);
|
||||
$until = now()->addDays(2);
|
||||
|
||||
// Add 5 items to cart
|
||||
$this->cart->addToCart($pool, 5, [], $from, $until);
|
||||
|
||||
// Pool has 7 total, 5 in cart, so 2 available for this request
|
||||
$this->assertEquals(7, $pool->getPoolMaxQuantity($from, $until));
|
||||
|
||||
// Should be able to add 2 more
|
||||
$this->cart->addToCart($pool, 2, [], $from, $until);
|
||||
$this->assertEquals(7, $this->cart->fresh()->items->sum('quantity'));
|
||||
|
||||
// Should NOT be able to add 1 more
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughStockException::class);
|
||||
$this->expectExceptionMessage('has only 0 items available');
|
||||
|
||||
$this->cart->addToCart($pool, 1, [], $from, $until);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function validation_message_shows_correct_remaining_availability()
|
||||
{
|
||||
$pool = $this->createPoolWith7Singles();
|
||||
|
||||
// Add 5 items without dates
|
||||
$this->cart->addToCart($pool, 5);
|
||||
|
||||
try {
|
||||
// Try to add 5 more (total would be 10, but max is 7)
|
||||
// Should fail saying only 2 available
|
||||
$this->cart->addToCart($pool, 5);
|
||||
$this->fail('Should have thrown NotEnoughStockException');
|
||||
} catch (\Blax\Shop\Exceptions\NotEnoughStockException $e) {
|
||||
$this->assertStringContainsString('has only 2 items available', $e->getMessage());
|
||||
$this->assertStringContainsString('Requested: 5', $e->getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -315,7 +315,7 @@ class PoolParkingCartPricingTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function config_a_validates_availability_when_setting_dates()
|
||||
public function config_a_marks_items_unavailable_when_setting_dates_to_unavailable_period()
|
||||
{
|
||||
$this->cart = $this->createCart();
|
||||
['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false);
|
||||
|
|
@ -334,8 +334,13 @@ class PoolParkingCartPricingTest extends TestCase
|
|||
$spots[2]->claimStock(2, null, $from, $until);
|
||||
|
||||
// Try to set dates for period when no stock is available
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
|
||||
// Should NOT throw, but mark items as unavailable
|
||||
$this->cart->setDates($from, $until, validateAvailability: true);
|
||||
|
||||
// Item should be marked as unavailable (null price)
|
||||
$item = $this->cart->items()->first();
|
||||
$this->assertNull($item->price, 'Unavailable item should have null price');
|
||||
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
|
@ -667,7 +672,7 @@ class PoolParkingCartPricingTest extends TestCase
|
|||
// ==========================================
|
||||
|
||||
/** @test */
|
||||
public function set_dates_validates_availability_for_each_cart_item()
|
||||
public function set_dates_marks_items_unavailable_when_all_claimed()
|
||||
{
|
||||
$this->cart = $this->createCart();
|
||||
['pool' => $pool, 'spots' => $spots] = $this->createParkingPool(hasPoolPrice: true, poolManagesStock: false);
|
||||
|
|
@ -686,10 +691,17 @@ class PoolParkingCartPricingTest extends TestCase
|
|||
$spots[1]->claimStock(2, null, $from, $until);
|
||||
$spots[2]->claimStock(2, null, $from, $until);
|
||||
|
||||
// Setting dates should validate and throw exception
|
||||
// because ALL spots are claimed for this period and we need 5
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
|
||||
// Setting dates should NOT throw, but mark items as unavailable
|
||||
$this->cart->setDates($from, $until, validateAvailability: true);
|
||||
|
||||
// All items should be marked as unavailable (null price)
|
||||
$this->cart->refresh();
|
||||
$this->cart->load('items');
|
||||
foreach ($this->cart->items as $item) {
|
||||
$this->assertNull($item->price, 'Unavailable item should have null price');
|
||||
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready');
|
||||
}
|
||||
$this->assertFalse($this->cart->is_ready_to_checkout, 'Cart should not be ready');
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
|
|
@ -428,16 +428,24 @@ class PoolProductionBugTest extends TestCase
|
|||
$this->assertFalse($secondCart->isReadyForCheckout());
|
||||
$this->assertFalse($secondCart->IsReadyToCheckout);
|
||||
|
||||
$this->assertThrows(
|
||||
fn() => $secondCart->setDates($from1, $until1),
|
||||
\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class
|
||||
);
|
||||
// Setting dates to a fully booked period should NOT throw,
|
||||
// but mark items as unavailable instead
|
||||
$secondCart->setDates($from1, $until1);
|
||||
|
||||
// All items should be marked as unavailable
|
||||
$secondCart->refresh();
|
||||
$secondCart->load('items');
|
||||
foreach ($secondCart->items as $item) {
|
||||
$this->assertNull($item->price, 'Item should have null price for unavailable period');
|
||||
$this->assertFalse($item->is_ready_to_checkout);
|
||||
}
|
||||
$this->assertFalse($secondCart->isReadyForCheckout());
|
||||
|
||||
// Now second user tries different dates - should succeed
|
||||
$from2 = Carbon::tomorrow()->addDays(2)->startOfDay();
|
||||
$until2 = Carbon::tomorrow()->addDays(3)->startOfDay(); // 1 day later
|
||||
|
||||
// This should work without exception
|
||||
// This should work - items become available again with new dates
|
||||
$secondCart->setDates($from2, $until2);
|
||||
$this->assertTrue($secondCart->isReadyForCheckout());
|
||||
$this->assertTrue($secondCart->isReadyToCheckout);
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ class PoolSmartAllocationTest extends TestCase
|
|||
}
|
||||
|
||||
/**
|
||||
* Test: User1 purchases items, User2 can add same items for different dates
|
||||
* Test: User1 purchases items, User2 can add same items but only available ones get allocated
|
||||
*/
|
||||
/** @test */
|
||||
public function user2_can_book_same_items_for_different_dates_after_user1_purchase()
|
||||
|
|
@ -179,6 +179,7 @@ class PoolSmartAllocationTest extends TestCase
|
|||
$purchaseFrom = Carbon::yesterday()->startOfDay();
|
||||
$purchaseUntil = Carbon::tomorrow()->addDay()->startOfDay();
|
||||
|
||||
// User1 books 5 of 6 available singles
|
||||
$user1Cart->addToCart($this->pool, 5, [], $purchaseFrom, $purchaseUntil);
|
||||
$user1Cart->checkout();
|
||||
|
||||
|
|
@ -194,9 +195,22 @@ class PoolSmartAllocationTest extends TestCase
|
|||
$this->assertEquals(6, $user2Cart->fresh()->items->sum('quantity'));
|
||||
$this->assertFalse($user2Cart->fresh()->isReadyForCheckout(), 'Cart should not be ready without dates');
|
||||
|
||||
// User2 tries to set dates that conflict with User1
|
||||
$this->expectException(\Blax\Shop\Exceptions\NotEnoughAvailableInTimespanException::class);
|
||||
// User2 sets dates that conflict with User1's booking
|
||||
// Only 1 single is still available (User1 took 5)
|
||||
$user2Cart->setDates($purchaseFrom, $purchaseUntil);
|
||||
|
||||
// 5 items should be unavailable (null price), 1 should be available
|
||||
$user2Cart->refresh();
|
||||
$user2Cart->load('items');
|
||||
|
||||
$availableItems = $user2Cart->items->filter(fn($item) => $item->price !== null && $item->price > 0);
|
||||
$unavailableItems = $user2Cart->items->filter(fn($item) => $item->price === null);
|
||||
|
||||
$this->assertEquals(1, $availableItems->count(), 'Should have 1 available item (6th single not booked by user1)');
|
||||
$this->assertEquals(5, $unavailableItems->count(), 'Should have 5 unavailable items (user1 booked those singles)');
|
||||
|
||||
// Cart should NOT be ready (has unavailable items)
|
||||
$this->assertFalse($user2Cart->isReadyForCheckout(), 'Cart should not be ready with unavailable items');
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -234,29 +234,31 @@ class CartTest extends TestCase
|
|||
}
|
||||
|
||||
/** @test */
|
||||
public function checkout_session_link_is_null_when_stripe_disabled()
|
||||
public function checkout_session_link_throws_when_stripe_disabled()
|
||||
{
|
||||
config(['shop.stripe.enabled' => false]);
|
||||
|
||||
$cart = Cart::create();
|
||||
|
||||
$this->assertNull($cart->checkoutSessionLink());
|
||||
// Now throws CartEmptyException (validation happens before stripe check)
|
||||
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
|
||||
$cart->checkoutSessionLink();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkout_session_link_returns_null_when_no_session_exists()
|
||||
public function checkout_session_link_throws_when_cart_empty()
|
||||
{
|
||||
config(['shop.stripe.enabled' => true]);
|
||||
|
||||
$cart = Cart::create();
|
||||
|
||||
$link = $cart->checkoutSessionLink();
|
||||
|
||||
$this->assertNull($link);
|
||||
// Now throws CartEmptyException instead of returning null
|
||||
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
|
||||
$cart->checkoutSessionLink();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function checkout_session_link_returns_null_when_session_id_empty()
|
||||
public function checkout_session_link_throws_when_cart_empty_even_with_meta()
|
||||
{
|
||||
config(['shop.stripe.enabled' => true]);
|
||||
|
||||
|
|
@ -264,9 +266,9 @@ class CartTest extends TestCase
|
|||
'meta' => ['other_data' => 'value'],
|
||||
]);
|
||||
|
||||
$link = $cart->checkoutSessionLink();
|
||||
|
||||
$this->assertNull($link);
|
||||
// Now throws CartEmptyException instead of returning null
|
||||
$this->expectException(\Blax\Shop\Exceptions\CartEmptyException::class);
|
||||
$cart->checkoutSessionLink();
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
Loading…
Reference in New Issue