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(); } /** @test */ public function checkoutSessionLink_throws_when_items_have_null_price() { $pool = $this->createPoolWithLimitedSingles(3); $from = now()->addDays(1); $until = now()->addDays(2); // Add items $this->cart->addToCart($pool, 3, [], $from, $until); // Manually make one unavailable $item = $this->cart->items()->first(); $item->update(['price' => null, 'subtotal' => null]); // checkoutSessionLink should throw because item is unavailable $this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class); $this->cart->checkoutSessionLink(); } /** @test */ public function checkoutSessionLink_throws_when_items_have_zero_price() { $pool = $this->createPoolWithLimitedSingles(3); $from = now()->addDays(1); $until = now()->addDays(2); // Add items $this->cart->addToCart($pool, 3, [], $from, $until); // Manually set price to 0 (should also be considered unavailable) $item = $this->cart->items()->first(); $item->update(['price' => 0, 'subtotal' => 0]); // checkoutSessionLink should throw because item has 0 price $this->expectException(\Blax\Shop\Exceptions\CartItemMissingInformationException::class); $this->cart->checkoutSessionLink(); } /** @test */ public function pool_items_maintain_consistent_pricing_after_date_changes() { $pool = $this->createPoolWithLimitedSingles(3); $from1 = now()->addDays(1); $until1 = now()->addDays(2); // Add 3 items with dates $this->cart->addToCart($pool, 3, [], $from1, $until1); // Get initial prices $initialPrices = $this->cart->items->pluck('price')->sort()->values()->toArray(); // Change to different dates (same duration) $from2 = now()->addDays(5); $until2 = now()->addDays(6); $this->cart->setDates($from2, $until2); $this->cart->refresh(); $this->cart->load('items'); // Prices should be the same (only dates changed, not duration) $newPrices = $this->cart->items->pluck('price')->sort()->values()->toArray(); $this->assertEquals( $initialPrices, $newPrices, 'Prices should remain consistent when only dates change (same duration)' ); } /** @test */ public function price_zero_is_treated_as_unavailable() { $pool = $this->createPoolWithLimitedSingles(3); $from = now()->addDays(1); $until = now()->addDays(2); $this->cart->addToCart($pool, 3, [], $from, $until); // Set price to 0 (simulating an old bug where 0 was used instead of null) $item = $this->cart->items()->first(); $item->update(['price' => 0, 'subtotal' => 0]); $item->refresh(); // Item should NOT be ready for checkout $this->assertFalse($item->is_ready_to_checkout, 'Item with price 0 should not be ready'); // requiredAdjustments should show price as unavailable $adjustments = $item->requiredAdjustments(); $this->assertArrayHasKey('price', $adjustments); $this->assertEquals('unavailable', $adjustments['price']); // Cart should NOT be ready $this->assertFalse($this->cart->fresh()->is_ready_to_checkout); } }