laravel-shop/tests/Feature/ProductionBugs/PoolPricingReallocationBugT...

749 lines
24 KiB
PHP

<?php
namespace Blax\Shop\Tests\Feature\ProductionBugs;
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 Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Production Bug: Pool pricing jumps to 5000 when adjusting dates
*
* Scenario:
* - Pool: Parkings (price: 2800)
* - Singles: Vip 1, Vip 2, Vip 3 (NO price - should use pool's 2800)
* - Singles: Executive 1, Executive 2 (price: 5000 each)
* - All singles have 1 stock
*
* Bug: getCurrentPrice on pool shows 2800 (correct), but when dates are adjusted,
* the cart item price jumps to 5000.
*
* Expected: With LOWEST pricing strategy, items should be allocated to Vip items first
* (using pool fallback price of 2800), not Executive items (5000).
*/
class PoolPricingReallocationBugTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected Product $pool;
protected array $vipItems = [];
protected array $executiveItems = [];
protected function setUp(): void
{
parent::setUp();
$this->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'
);
}
}