laravel-shop/tests/Feature/Pool/PoolProductTest.php

614 lines
20 KiB
PHP
Raw Permalink Normal View History

<?php
2025-12-30 09:55:06 +00:00
namespace Blax\Shop\Tests\Feature\Pool;
use Blax\Shop\Enums\ProductRelationType;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Workbench\App\Models\User;
2025-12-24 18:40:10 +00:00
use PHPUnit\Framework\Attributes\Test;
class PoolProductTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $hotelRoom;
protected Product $parkingPool;
protected Product $parkingSpot1;
protected Product $parkingSpot2;
protected Product $parkingSpot3;
protected ProductPrice $hotelPrice;
protected ProductPrice $parkingPrice;
protected function setUp(): void
{
parent::setUp();
$this->user = User::factory()->create();
// Create hotel room (booking product)
$this->hotelRoom = Product::factory()->create([
'name' => 'Hotel Room',
'slug' => 'hotel-room',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->hotelRoom->increaseStock(5);
$this->hotelPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->hotelRoom->id,
'purchasable_type' => Product::class,
'unit_amount' => 10000, // $100.00 per day
'currency' => 'USD',
'is_default' => true,
]);
// Create parking pool product
$this->parkingPool = Product::factory()->create([
'name' => 'Parking Spaces',
'slug' => 'parking-spaces',
'type' => ProductType::POOL,
'manage_stock' => false, // Pool doesn't manage its own stock
]);
$this->parkingPrice = ProductPrice::factory()->create([
'purchasable_id' => $this->parkingPool->id,
'purchasable_type' => Product::class,
'unit_amount' => 2000, // $20.00 per day
'currency' => 'USD',
'is_default' => true,
]);
// Create individual parking spots (booking products with stock = 1)
$this->parkingSpot1 = Product::factory()->create([
'name' => 'Parking Spot 1',
'slug' => 'parking-spot-1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->parkingSpot1->increaseStock(1);
$this->parkingSpot2 = Product::factory()->create([
'name' => 'Parking Spot 2',
'slug' => 'parking-spot-2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->parkingSpot2->increaseStock(1);
$this->parkingSpot3 = Product::factory()->create([
'name' => 'Parking Spot 3',
'slug' => 'parking-spot-3',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$this->parkingSpot3->increaseStock(1);
// Link parking spots as SINGLE items to the pool
$this->parkingPool->productRelations()->attach($this->parkingSpot1->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$this->parkingPool->productRelations()->attach($this->parkingSpot2->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$this->parkingPool->productRelations()->attach($this->parkingSpot3->id, [
'type' => ProductRelationType::SINGLE->value,
]);
// Link parking pool as cross-sell to hotel room
$this->hotelRoom->productRelations()->attach($this->parkingPool->id, [
'type' => ProductRelationType::CROSS_SELL->value,
]);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function it_can_create_a_pool_product()
{
$this->assertNotNull($this->parkingPool);
$this->assertEquals(ProductType::POOL, $this->parkingPool->type);
$this->assertTrue($this->parkingPool->isPool());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_has_single_items_linked()
{
$singleItems = $this->parkingPool->singleProducts;
$this->assertCount(3, $singleItems);
$this->assertTrue($singleItems->contains($this->parkingSpot1));
$this->assertTrue($singleItems->contains($this->parkingSpot2));
$this->assertTrue($singleItems->contains($this->parkingSpot3));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_max_quantity_equals_number_of_single_items()
{
$maxQuantity = $this->parkingPool->getPoolMaxQuantity();
$this->assertEquals(3, $maxQuantity);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_detects_booking_single_items()
{
$this->assertTrue($this->parkingPool->hasBookingSingleItems());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function it_can_add_pool_product_to_cart_with_timespan()
{
$cart = $this->user->currentCart();
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$cartItem = $cart->items()->create([
'purchasable_id' => $this->parkingPool->id,
'purchasable_type' => Product::class,
'quantity' => 2,
'price' => 20.00,
'from' => $from,
'until' => $until,
]);
$this->assertNotNull($cartItem);
$this->assertEquals($from->format('Y-m-d H:i:s'), $cartItem->from->format('Y-m-d H:i:s'));
$this->assertEquals($until->format('Y-m-d H:i:s'), $cartItem->until->format('Y-m-d H:i:s'));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_quantity_is_limited_by_available_single_items()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
// All 3 parking spots are available
$maxQuantity = $this->parkingPool->getPoolMaxQuantity($from, $until);
$this->assertEquals(3, $maxQuantity);
// Book one parking spot directly
$this->parkingSpot1->claimStock(1, null, $from, $until);
// Now only 2 should be available in the pool
$maxQuantity = $this->parkingPool->getPoolMaxQuantity($from, $until);
$this->assertEquals(2, $maxQuantity);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function booking_price_is_calculated_based_on_timespan_and_quantity()
{
$from = Carbon::now()->addDays(1)->startOfDay();
$until = Carbon::now()->addDays(3)->startOfDay();
$days = $from->diffInDays($until);
$quantity = 2;
$pricePerDay = 20.00;
$expectedTotal = $days * $quantity * $pricePerDay;
// This would be $2 days * 2 parking spaces * $20 = $80
$this->assertEquals(80.00, $expectedTotal);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_overlapping_bookings_reduces_available_quantity()
{
$from = Carbon::now()->addDays(5);
$until = Carbon::now()->addDays(7);
// Initially all 3 spots available
$this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until));
// Book 2 spots
$this->parkingSpot1->claimStock(1, null, $from, $until);
$this->parkingSpot2->claimStock(1, null, $from, $until);
// Only 1 spot should remain available
$this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function different_timespan_bookings_dont_conflict()
{
$from1 = Carbon::now()->addDays(1);
$until1 = Carbon::now()->addDays(3);
$from2 = Carbon::now()->addDays(5);
$until2 = Carbon::now()->addDays(7);
// Book spot 1 for first period
$this->parkingSpot1->claimStock(1, null, $from1, $until1);
// All spots should be available for second period
$this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from2, $until2));
// Only 2 spots should be available for first period
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from1, $until1));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_unavailable_when_all_single_items_booked()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
// Book all 3 spots
$this->parkingSpot1->claimStock(1, null, $from, $until);
$this->parkingSpot2->claimStock(1, null, $from, $until);
$this->parkingSpot3->claimStock(1, null, $from, $until);
// No spots should be available
$this->assertEquals(0, $this->parkingPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_can_be_cross_sell_of_hotel_room()
{
$crossSells = $this->hotelRoom->crossSellProducts;
$this->assertCount(1, $crossSells);
$this->assertTrue($crossSells->contains($this->parkingPool));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function booking_cancellation_releases_stock_of_single_items()
{
$from = Carbon::now()->addDays(10);
$until = Carbon::now()->addDays(12);
// Book a spot
$this->parkingSpot1->claimStock(1, null, $from, $until);
// Should have 2 spots available
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until));
// Release the stock (simulate cancellation before booking starts)
$claim = $this->parkingSpot1->stocks()
->where('type', StockType::CLAIMED->value)
->where('claimed_from', $from)
->where('expires_at', $until)
->first();
if ($claim) {
$claim->release(); // Use the release method instead of delete
}
// Should have 3 spots available again
$this->parkingSpot1->refresh();
$this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_respects_partial_overlapping_bookings()
{
// Booking 1: Days 1-3
$from1 = Carbon::now()->addDays(1);
$until1 = Carbon::now()->addDays(3);
// Booking 2: Days 2-4 (overlaps with booking 1 on day 2)
$from2 = Carbon::now()->addDays(2);
$until2 = Carbon::now()->addDays(4);
// Book spot 1 for days 1-3
$this->parkingSpot1->claimStock(1, null, $from1, $until1);
// For days 2-4, spot 1 should not be available (overlaps)
// So only 2 spots should be available
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from2, $until2));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function multiple_pool_products_can_exist_independently()
{
// Create a second pool for bikes
$bikePool = Product::factory()->create([
'name' => 'Bike Rentals',
'slug' => 'bike-rentals',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$bike1 = Product::factory()->create([
'name' => 'Bike 1',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$bike1->increaseStock(1);
$bike2 = Product::factory()->create([
'name' => 'Bike 2',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$bike2->increaseStock(1);
$bikePool->productRelations()->attach($bike1->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$bikePool->productRelations()->attach($bike2->id, [
'type' => ProductRelationType::SINGLE->value,
]);
// Both pools should work independently
$this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity());
$this->assertEquals(2, $bikePool->getPoolMaxQuantity());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_stock_calculated_correctly_with_mixed_availability()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
// Spot 1: Fully booked for the period
$this->parkingSpot1->claimStock(1, null, $from, $until);
// Spot 2: Available
// Spot 3: Booked for a different period
$otherFrom = Carbon::now()->addDays(5);
$otherUntil = Carbon::now()->addDays(7);
$this->parkingSpot3->claimStock(1, null, $otherFrom, $otherUntil);
// For the requested period (days 1-3), spots 2 and 3 should be available
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_zero_single_items_returns_zero_max_quantity()
{
$emptyPool = Product::factory()->create([
'name' => 'Empty Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$this->assertEquals(0, $emptyPool->getPoolMaxQuantity());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_non_booking_single_items_doesnt_require_timespan()
{
$simplePool = Product::factory()->create([
'name' => 'Simple Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$simpleItem = Product::factory()->create([
'name' => 'Simple Item',
'type' => ProductType::SIMPLE,
'manage_stock' => false,
]);
$simplePool->productRelations()->attach($simpleItem->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$this->assertFalse($simplePool->hasBookingSingleItems());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_mixed_booking_and_non_booking_single_items()
{
$mixedPool = Product::factory()->create([
'name' => 'Mixed Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$bookingItem = Product::factory()->create([
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$bookingItem->increaseStock(1);
$simpleItem = Product::factory()->create([
'type' => ProductType::SIMPLE,
'manage_stock' => false,
]);
$mixedPool->productRelations()->attach($bookingItem->id, [
'type' => ProductRelationType::SINGLE->value,
]);
$mixedPool->productRelations()->attach($simpleItem->id, [
'type' => ProductRelationType::SINGLE->value,
]);
// Should detect booking items exist
$this->assertTrue($mixedPool->hasBookingSingleItems());
// Pool max quantity should be the stock of managed items (1 from booking item)
// Unlimited items (simple item) don't contribute to the count
$this->assertEquals(1, $mixedPool->getPoolMaxQuantity());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_checkout_claims_exactly_the_right_number_of_single_items()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$cart = $this->user->currentCart();
$cart->items()->create([
'purchasable_id' => $this->parkingPool->id,
'purchasable_type' => Product::class,
'quantity' => 2,
'price' => 20.00,
'from' => $from,
'until' => $until,
]);
$cart->checkout();
// Verify 2 single items were claimed
$claimedCount = 0;
foreach ($this->parkingPool->singleProducts as $spot) {
$claims = $spot->stocks()
->where('type', StockType::CLAIMED->value)
->where('claimed_from', $from)
->count();
$claimedCount += $claims;
}
$this->assertEquals(2, $claimedCount);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_checkout_stores_claimed_single_items_in_metadata()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$cart = $this->user->currentCart();
$cartItem = $cart->items()->create([
'purchasable_id' => $this->parkingPool->id,
'purchasable_type' => Product::class,
'quantity' => 2,
'price' => 20.00,
'from' => $from,
'until' => $until,
]);
$cart->checkout();
$cartItem->refresh();
$meta = $cartItem->getMeta();
$claimedItems = $meta->claimed_single_items ?? null;
$this->assertNotNull($claimedItems);
$this->assertIsArray($claimedItems);
$this->assertCount(2, $claimedItems);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_different_stock_quantities_on_single_items()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
// Create a spot with stock of 2
$doubleSpot = Product::factory()->create([
'name' => 'Double Parking Spot',
'type' => ProductType::BOOKING,
'manage_stock' => true,
]);
$doubleSpot->increaseStock(2);
$customPool = Product::factory()->create([
'name' => 'Custom Pool',
'type' => ProductType::POOL,
'manage_stock' => false,
]);
$customPool->productRelations()->attach($doubleSpot->id, [
'type' => ProductRelationType::SINGLE->value,
]);
// Should count the stock quantity (2), not just the number of items (1)
$this->assertEquals(2, $customPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function claim_pool_stock_throws_exception_when_not_enough_single_items_available()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
// Claim 2 spots first
$this->parkingSpot1->claimStock(1, null, $from, $until);
$this->parkingSpot2->claimStock(1, null, $from, $until);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('available');
// Try to claim 2 more (only 1 available)
$this->parkingPool->claimPoolStock(2, null, $from, $until);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function claim_pool_stock_throws_exception_when_called_on_non_pool_product()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('only for pool products');
$this->hotelRoom->claimPoolStock(1, null, $from, $until);
}
2025-12-24 18:40:10 +00:00
#[Test]
public function release_pool_stock_correctly_releases_all_claims()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$reference = $this->user->currentCart();
// Claim 2 spots
$this->parkingPool->claimPoolStock(2, $reference, $from, $until);
// Verify they're claimed
$this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until));
// Release them
$released = $this->parkingPool->releasePoolStock($reference);
$this->assertEquals(2, $released);
$this->assertEquals(3, $this->parkingPool->getPoolMaxQuantity($from, $until));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function release_pool_stock_throws_exception_when_called_on_non_pool_product()
{
$this->expectException(\Exception::class);
$this->expectExceptionMessage('only for pool products');
$this->hotelRoom->releasePoolStock($this->user->currentCart());
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_with_single_item_already_claimed_for_entire_period()
{
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(5);
// Claim spot 1 for the entire period
$this->parkingSpot1->claimStock(1, null, $from, $until);
// Should still have 2 spots available
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($from, $until));
// Claim spot 2 for part of the period
$partialFrom = Carbon::now()->addDays(2);
$partialUntil = Carbon::now()->addDays(4);
$this->parkingSpot2->claimStock(1, null, $partialFrom, $partialUntil);
// For the entire period, only spot 3 should be available
$this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($from, $until));
// For the partial period, spot 3 should still be available (spot 1 is busy)
$this->assertEquals(1, $this->parkingPool->getPoolMaxQuantity($partialFrom, $partialUntil));
}
2025-12-24 18:40:10 +00:00
#[Test]
public function pool_product_maximum_quantity_with_edge_of_timespan()
{
// Claim 1: Days 1-3
$claim1From = Carbon::now()->addDays(1)->startOfDay();
$claim1Until = Carbon::now()->addDays(3)->endOfDay();
// Claim 2: Days 3-5 (overlaps on day 3)
$claim2From = Carbon::now()->addDays(3)->startOfDay();
$claim2Until = Carbon::now()->addDays(5)->endOfDay();
$this->parkingSpot1->claimStock(1, null, $claim1From, $claim1Until);
// For days 3-5, spot 1 should still be unavailable due to overlap
$this->assertEquals(2, $this->parkingPool->getPoolMaxQuantity($claim2From, $claim2Until));
}
}