user = User::factory()->create(); auth()->login($this->user); $this->createParkingPool(); } /** * Create the parking pool with Vip (no price) and Executive (5000) items */ protected function createParkingPool(): void { // Create pool product with price 2800 $this->pool = Product::factory()->create([ 'name' => 'Parkings', 'type' => ProductType::POOL, 'manage_stock' => false, ]); // Set pricing strategy to lowest $this->pool->setPoolPricingStrategy('lowest'); // Pool has price of 2800 ProductPrice::factory()->create([ 'purchasable_id' => $this->pool->id, 'purchasable_type' => Product::class, 'unit_amount' => 2800, 'currency' => 'USD', 'is_default' => true, ]); // Create 3 Vip items WITHOUT prices (should fallback to pool price 2800) for ($i = 1; $i <= 3; $i++) { $vip = Product::factory()->create([ 'name' => "Vip $i", 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $vip->increaseStock(1); // NO price - should use pool fallback $this->vipItems[] = $vip; } // Create 2 Executive items WITH prices of 5000 for ($i = 1; $i <= 2; $i++) { $exec = Product::factory()->create([ 'name' => "Executive $i", 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $exec->increaseStock(1); ProductPrice::factory()->create([ 'purchasable_id' => $exec->id, 'purchasable_type' => Product::class, 'unit_amount' => 5000, 'currency' => 'USD', 'is_default' => true, ]); $this->executiveItems[] = $exec; } // Attach all singles to pool (Vip items first, then Executive) $allSingles = array_merge( array_map(fn($p) => $p->id, $this->vipItems), array_map(fn($p) => $p->id, $this->executiveItems) ); $this->pool->attachSingleItems($allSingles); } // ========================================================================= // Basic price verification tests // ========================================================================= #[Test] public function pool_get_current_price_returns_2800() { // getCurrentPrice should return 2800 (the pool's price) $price = $this->pool->getCurrentPrice(); $this->assertEquals(2800, $price, 'Pool getCurrentPrice should return 2800'); } #[Test] public function vip_items_have_no_direct_price() { foreach ($this->vipItems as $vip) { $price = $vip->defaultPrice()->first(); $this->assertNull($price, "Vip item {$vip->name} should have no price"); } } #[Test] public function executive_items_have_price_5000() { foreach ($this->executiveItems as $exec) { $priceModel = $exec->defaultPrice()->first(); $this->assertNotNull($priceModel, "Executive item {$exec->name} should have a price"); $this->assertEquals( 5000, $priceModel->getCurrentPrice(), "Executive item {$exec->name} should have price 5000" ); } } // ========================================================================= // Cart pricing tests - adding to cart // ========================================================================= #[Test] public function add_first_item_to_cart_should_use_lowest_price_2800() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); // Should be allocated to a Vip item (using pool fallback price 2800) $this->assertNotNull($item, 'Cart should have an item'); $this->assertEquals( 2800, $item->price, 'First item should use lowest price (2800 from pool fallback)' ); // Verify allocated to a Vip item (now stored in product_id column) $allocatedSingleId = $item->product_id; $vipIds = array_map(fn($p) => $p->id, $this->vipItems); $this->assertContains( $allocatedSingleId, $vipIds, 'Item should be allocated to a Vip single (lowest price)' ); } #[Test] public function add_multiple_items_should_fill_vip_before_executive() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); // Add 3 items - should fill all 3 Vip spots at 2800 each $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 3, [], $from, $until); $cart->refresh(); $totalPrice = $cart->items->sum('price'); // 3 x 2800 = 8400 $this->assertEquals( 8400, $totalPrice, 'Three items should cost 8400 (3 x 2800 from Vip items)' ); } #[Test] public function adding_4th_item_should_use_executive_at_5000() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); // Add 4 items - 3 Vip at 2800 + 1 Executive at 5000 $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 4, [], $from, $until); $cart->refresh(); $totalPrice = $cart->items->sum('price'); // 3 x 2800 + 1 x 5000 = 8400 + 5000 = 13400 $this->assertEquals( 13400, $totalPrice, 'Four items should cost 13400 (3 x 2800 + 1 x 5000)' ); } // ========================================================================= // BUG REPRODUCTION: Date adjustment causes price jump // ========================================================================= #[Test] public function adjusting_dates_should_maintain_2800_price_for_vip_allocation() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); // Add 1 item - should be allocated to Vip at 2800 $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $this->assertEquals(2800, $item->price, 'Initial price should be 2800'); // Now adjust dates $newFrom = Carbon::now()->addDays(3); $newUntil = Carbon::now()->addDays(4); $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); // BUG: Price should still be 2800, NOT 5000 $this->assertEquals( 2800, $item->price, 'After adjusting dates, price should still be 2800 (not jump to 5000)' ); } #[Test] public function adjusting_until_date_should_maintain_lowest_price() { $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // First set cart dates, then add item $cart = $this->user->currentCart(); $cart->setFromDate($from); $cart->setUntilDate($until); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $initialPrice = $cart->items->first()->price; $this->assertEquals(2800, $initialPrice, 'Initial price for 1 day should be 2800'); // Now extend the until date to add more days $newUntil = Carbon::now()->addDays(4)->startOfDay(); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); $days = 3; // from addDay() (day 1) to addDays(4) (day 4) = 3 days $expectedPrice = 2800 * $days; // Price should scale with days but base should still be 2800 $this->assertEquals( $expectedPrice, $item->price, "After extending until date, price should be {$expectedPrice} (2800 x {$days} days)" ); } #[Test] public function updating_cart_item_dates_directly_should_maintain_lowest_price() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $this->assertEquals(2800, $item->price, 'Initial price should be 2800'); // Update dates directly on the cart item $newFrom = Carbon::now()->addDays(5); $newUntil = Carbon::now()->addDays(6); $item->updateDates($newFrom, $newUntil); $item->refresh(); // BUG: Price should still be 2800, NOT 5000 $this->assertEquals( 2800, $item->price, 'After updating item dates directly, price should still be 2800' ); } #[Test] public function price_should_stay_2800_when_reallocating_with_dates_where_vip_is_available() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); // Add 1 item $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $originalAllocation = $item->product_id; // Record original price $originalPrice = $item->price; $this->assertEquals(2800, $originalPrice); // Change to different dates where same Vip should still be available $newFrom = Carbon::now()->addDays(10); $newUntil = Carbon::now()->addDays(11); $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); // Price should remain 2800 (still allocated to a Vip) $this->assertEquals( 2800, $item->price, 'Price should remain 2800 after date change when Vip items are available' ); // Should still be allocated to a Vip item $allocatedSingleId = $item->product_id; $vipIds = array_map(fn($p) => $p->id, $this->vipItems); $this->assertContains( $allocatedSingleId, $vipIds, 'Should remain allocated to a Vip item after date change' ); } // ========================================================================= // Edge cases // ========================================================================= #[Test] public function when_all_vip_claimed_new_item_gets_executive_at_5000() { $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // Fill all 3 Vip spots by claiming stock foreach ($this->vipItems as $vip) { $vip->claimStock(1, null, $from, $until, 'Test claim'); } // Now add 1 item - should get Executive at 5000 (since all Vips are claimed) $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $this->assertEquals( 5000, $item->price, 'Item should be allocated to Executive at 5000 when all Vips are claimed' ); // Verify allocated to an Executive item $allocatedSingleId = $item->product_id; $execIds = array_map(fn($p) => $p->id, $this->executiveItems); $this->assertContains( $allocatedSingleId, $execIds, 'Item should be allocated to an Executive single' ); } #[Test] public function reallocation_after_date_change_respects_pricing_strategy() { // Use different dates to avoid conflicts $from1 = Carbon::now()->addDays(1); $until1 = Carbon::now()->addDays(2); $from2 = Carbon::now()->addDays(10); $until2 = Carbon::now()->addDays(11); // Add 2 items at dates1 $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 2, [], $from1, $until1); $cart->refresh(); $prices = $cart->items->pluck('price')->toArray(); $this->assertEquals( [2800, 2800], $prices, 'Both items should be 2800 (allocated to Vip items)' ); // Change dates to dates2 where all singles should be available $cart->setFromDate($from2); $cart->setUntilDate($until2); $cart->refresh(); $prices = $cart->items->pluck('price')->toArray(); // Should still be allocated to lowest-priced items (Vip at 2800) $this->assertEquals( [2800, 2800], $prices, 'After date change, both items should still be 2800' ); } #[Test] public function multiple_date_adjustments_maintain_correct_pricing() { $from = Carbon::now()->addDay(); $until = Carbon::now()->addDays(2); $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $this->assertEquals(2800, $cart->items->first()->price); // Adjust dates multiple times for ($i = 0; $i < 5; $i++) { $newFrom = Carbon::now()->addDays(10 + $i * 5); $newUntil = Carbon::now()->addDays(11 + $i * 5); $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $this->assertEquals( 2800, $cart->items->first()->price, "After adjustment #{$i}, price should still be 2800" ); } } // ========================================================================= // Additional edge case tests for production bug investigation // ========================================================================= #[Test] public function adding_item_without_dates_then_setting_dates_uses_lowest_price() { // Add item WITHOUT dates first $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1); $cart->refresh(); $item = $cart->items->first(); // Item should exist but may not have a full price yet (no dates) $this->assertNotNull($item); // Now set dates on the cart $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); $cart->setFromDate($from); $cart->setUntilDate($until); $cart->refresh(); $item = $cart->items->first(); // Should be allocated to Vip (lowest price 2800) $this->assertEquals( 2800, $item->price, 'After setting dates, price should be 2800 (lowest via Vip)' ); // Verify allocated to a Vip item $allocatedSingleId = $item->product_id; $vipIds = array_map(fn($p) => $p->id, $this->vipItems); $this->assertContains( $allocatedSingleId, $vipIds, 'Item should be allocated to a Vip single (lowest price)' ); } #[Test] public function setting_dates_on_cart_with_pool_item_allocates_to_lowest() { $cart = $this->user->currentCart(); // Set cart dates first $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); $cart->update(['from' => $from, 'until' => $until]); // Then add item without explicitly passing dates $cart->addToCart($this->pool, 1); $cart->refresh(); $item = $cart->items->first(); // Should be allocated to Vip (lowest price 2800) $this->assertEquals( 2800, $item->price, 'Item should use lowest price 2800 from Vip' ); } #[Test] public function debugging_reallocation_order() { $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $initialAllocation = $item->product_id; $initialPrice = $item->price; // Verify initial state $this->assertEquals(2800, $initialPrice, 'Initial price should be 2800'); $vipIds = array_map(fn($p) => $p->id, $this->vipItems); $this->assertContains($initialAllocation, $vipIds, 'Should be allocated to Vip'); // Change dates multiple times and track what happens for ($i = 1; $i <= 3; $i++) { $newFrom = Carbon::now()->addDays($i * 10)->startOfDay(); $newUntil = Carbon::now()->addDays($i * 10 + 1)->startOfDay(); $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); $newAllocation = $item->product_id; $newPrice = $item->price; // Verify still allocated to Vip and still 2800 $this->assertContains( $newAllocation, $vipIds, "After iteration {$i}, should still be allocated to Vip" ); $this->assertEquals( 2800, $newPrice, "After iteration {$i}, price should still be 2800" ); } } #[Test] public function cross_sell_pool_pricing_uses_lowest() { // Create a hotel room product $hotelRoom = Product::factory()->create([ 'name' => 'Hotel Room', 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $hotelRoom->increaseStock(5); ProductPrice::factory()->create([ 'purchasable_id' => $hotelRoom->id, 'purchasable_type' => Product::class, 'unit_amount' => 10000, 'currency' => 'USD', 'is_default' => true, ]); // Attach pool as cross-sell to hotel room $hotelRoom->productRelations()->attach($this->pool->id, [ 'type' => \Blax\Shop\Enums\ProductRelationType::CROSS_SELL->value, ]); $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // Add hotel room first $cart = $this->user->currentCart(); $cart->addToCart($hotelRoom, 1, [], $from, $until); // Add the cross-sell pool (parking) $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); // Find the parking item $parkingItem = $cart->items->first(fn($item) => $item->purchasable_id === $this->pool->id); $this->assertNotNull($parkingItem, 'Parking item should exist'); $this->assertEquals( 2800, $parkingItem->price, 'Parking cross-sell should use lowest price 2800' ); // Adjust dates $newFrom = Carbon::now()->addDays(5)->startOfDay(); $newUntil = Carbon::now()->addDays(6)->startOfDay(); $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $parkingItem = $cart->items->first(fn($item) => $item->purchasable_id === $this->pool->id); $this->assertEquals( 2800, $parkingItem->price, 'After date adjustment, parking should still be 2800' ); } #[Test] public function when_allocated_vip_becomes_unavailable_reallocates_to_next_cheapest() { $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // Add 1 item - should get allocated to Vip 1 at 2800 $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $allocatedVipId = $item->product_id; $this->assertEquals(2800, $item->price); // Now claim that specific Vip for different dates (simulating another booking) $newFrom = Carbon::now()->addDays(5)->startOfDay(); $newUntil = Carbon::now()->addDays(6)->startOfDay(); // Claim the allocated Vip for the new date range $allocatedVip = Product::find($allocatedVipId); $allocatedVip->claimStock(1, null, $newFrom, $newUntil, 'Other booking'); // Now change cart dates to the new range where that Vip is claimed $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); // Should be reallocated to another Vip (there are 3 Vips) // Price should still be 2800 (another Vip is available) $this->assertEquals( 2800, $item->price, 'When original Vip is claimed, should reallocate to another Vip at 2800' ); $newAllocatedId = $item->product_id; // Should be a different Vip $this->assertNotEquals( $allocatedVipId, $newAllocatedId, 'Should be reallocated to a different single item' ); $vipIds = array_map(fn($p) => $p->id, $this->vipItems); $this->assertContains( $newAllocatedId, $vipIds, 'Should still be allocated to a Vip item' ); } #[Test] public function when_all_vips_unavailable_reallocates_to_executive() { $from = Carbon::now()->addDay()->startOfDay(); $until = Carbon::now()->addDays(2)->startOfDay(); // Add 1 item - should get allocated to Vip 1 at 2800 $cart = $this->user->currentCart(); $cart->addToCart($this->pool, 1, [], $from, $until); $cart->refresh(); $item = $cart->items->first(); $this->assertEquals(2800, $item->price); // Now claim ALL Vips for different dates $newFrom = Carbon::now()->addDays(5)->startOfDay(); $newUntil = Carbon::now()->addDays(6)->startOfDay(); foreach ($this->vipItems as $vip) { $vip->claimStock(1, null, $newFrom, $newUntil, 'Other booking'); } // Change cart dates to the new range where all Vips are claimed $cart->setFromDate($newFrom); $cart->setUntilDate($newUntil); $cart->refresh(); $item = $cart->items->first(); // Should be reallocated to Executive at 5000 (only option left) $this->assertEquals( 5000, $item->price, 'When all Vips are claimed, should reallocate to Executive at 5000' ); $allocatedId = $item->product_id; $execIds = array_map(fn($p) => $p->id, $this->executiveItems); $this->assertContains( $allocatedId, $execIds, 'Should be allocated to an Executive item' ); } }