402 lines
13 KiB
PHP
402 lines
13 KiB
PHP
<?php
|
||
|
||
namespace Blax\Shop\Tests\Feature\Cart;
|
||
|
||
use Blax\Shop\Enums\ProductRelationType;
|
||
use Blax\Shop\Enums\ProductType;
|
||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||
use Blax\Shop\Facades\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 Workbench\App\Models\User;
|
||
use PHPUnit\Framework\Attributes\Test;
|
||
|
||
class CartServiceBookingTest extends TestCase
|
||
{
|
||
use RefreshDatabase;
|
||
|
||
protected User $user;
|
||
protected Product $bookingProduct;
|
||
protected Product $poolProduct;
|
||
protected Product $singleItem1;
|
||
protected Product $singleItem2;
|
||
protected ProductPrice $bookingPrice;
|
||
protected ProductPrice $poolPrice;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
|
||
$this->user = User::factory()->create();
|
||
$this->actingAs($this->user);
|
||
|
||
// Create booking product
|
||
$this->bookingProduct = Product::factory()
|
||
->withStocks(10)
|
||
->withPrices(1, 10000)
|
||
->create([
|
||
'name' => 'Hotel Room',
|
||
'type' => ProductType::BOOKING,
|
||
'manage_stock' => true,
|
||
]);
|
||
|
||
// Create pool product with single items
|
||
$this->poolProduct = Product::factory()
|
||
->withPrices(1, 2000)
|
||
->create([
|
||
'name' => 'Parking Spaces',
|
||
'type' => ProductType::POOL,
|
||
'manage_stock' => false,
|
||
]);
|
||
|
||
|
||
$this->singleItem1 = Product::factory()
|
||
->withStocks(1)
|
||
->create([
|
||
'name' => 'Parking Spot 1',
|
||
'type' => ProductType::BOOKING,
|
||
'manage_stock' => true,
|
||
]);
|
||
|
||
$this->singleItem2 = Product::factory()
|
||
->withStocks(1)
|
||
->create([
|
||
'name' => 'Parking Spot 2',
|
||
'type' => ProductType::BOOKING,
|
||
'manage_stock' => true,
|
||
]);
|
||
|
||
$this->poolProduct->productRelations()->attach($this->singleItem1->id, [
|
||
'type' => ProductRelationType::SINGLE->value,
|
||
]);
|
||
$this->poolProduct->productRelations()->attach($this->singleItem2->id, [
|
||
'type' => ProductRelationType::SINGLE->value,
|
||
]);
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_returns_error_for_booking_product_without_timespan()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$cart->addToCart($this->bookingProduct, 1);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
$this->assertNotEmpty($errors);
|
||
$this->assertStringContainsString('requires a timespan', $errors[0]);
|
||
$this->assertStringContainsString('Hotel Room', $errors[0]);
|
||
|
||
$this->assertEquals(10000, $cart->getTotal());
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_returns_error_for_pool_product_without_timespan_when_single_items_are_bookings()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$cart->addToCart($this->poolProduct, 1);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
$this->assertNotEmpty($errors);
|
||
$this->assertStringContainsString('requires either a timespan', $errors[0]);
|
||
$this->assertStringContainsString('Parking Spaces', $errors[0]);
|
||
|
||
$this->assertEquals(2000, $cart->getTotal());
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_validates_stock_availability_correctly()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
// Book all stock first
|
||
$this->bookingProduct->claimStock(10, null, $from, $until);
|
||
|
||
// Adding to cart should now succeed (lenient - uses total capacity)
|
||
// Date-based validation happens at validateBookings/checkout
|
||
$cart->addToCart($this->bookingProduct, 5, [], $from, $until);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
// validateBookings should detect the stock conflict
|
||
$this->assertNotEmpty($errors);
|
||
$this->assertStringContainsString('not available for the selected period', $errors[0]);
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_handles_pool_products_with_individual_timespans_in_meta()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
|
||
// Add pool product with individual timespans flag
|
||
$cartItem = $cart->items()->create([
|
||
'purchasable_id' => $this->poolProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 1,
|
||
'price' => 20.00,
|
||
'meta' => ['individual_timespans' => true],
|
||
]);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
// Should not have errors since individual timespans are marked
|
||
$this->assertEmpty($errors);
|
||
}
|
||
|
||
#[Test]
|
||
public function has_valid_bookings_returns_true_when_all_bookings_are_valid()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->bookingProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 2,
|
||
'price' => 100.00,
|
||
'from' => $from,
|
||
'until' => $until,
|
||
]);
|
||
|
||
$this->assertTrue(Cart::hasValidBookings());
|
||
}
|
||
|
||
#[Test]
|
||
public function has_valid_bookings_returns_false_when_bookings_are_invalid()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
|
||
// Add booking without timespan
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->bookingProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 1,
|
||
'price' => 100.00,
|
||
]);
|
||
|
||
$this->assertFalse(Cart::hasValidBookings());
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_successfully_adds_booking_product_with_timespan()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$cartItem = Cart::addBooking($this->bookingProduct, 2, $from, $until);
|
||
|
||
$this->assertNotNull($cartItem);
|
||
$this->assertEquals($this->bookingProduct->id, $cartItem->purchasable_id);
|
||
$this->assertEquals(2, $cartItem->quantity);
|
||
$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'));
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_successfully_adds_pool_product_with_timespan()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$cartItem = Cart::addBooking($this->poolProduct, 1, $from, $until);
|
||
|
||
$this->assertNotNull($cartItem);
|
||
$this->assertEquals($this->poolProduct->id, $cartItem->purchasable_id);
|
||
$this->assertEquals(1, $cartItem->quantity);
|
||
$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'));
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_calculates_price_correctly_based_on_days()
|
||
{
|
||
$from = Carbon::now()->addDays(1)->startOfDay();
|
||
$until = Carbon::now()->addDays(4)->startOfDay(); // 3 days
|
||
$days = $from->diffInDays($until);
|
||
|
||
$cartItem = Cart::addBooking($this->bookingProduct, 2, $from, $until);
|
||
|
||
// withPrices(1, 10000) stores 10000 cents
|
||
// Price should be: price_per_day (10000 cents) × days (3) = 30000 cents per unit
|
||
// Total should be: 30000 × quantity (2) = 60000 cents
|
||
$pricePerDay = 10000; // 100.00 euros = 10000 cents
|
||
$expectedPricePerUnit = $pricePerDay * $days; // 30000 cents
|
||
$expectedTotal = $expectedPricePerUnit * 2; // 60000 cents
|
||
|
||
$this->assertEquals($expectedPricePerUnit, $cartItem->price);
|
||
$this->assertEquals($expectedTotal, $cartItem->subtotal);
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_throws_exception_when_product_is_not_booking_or_pool_type()
|
||
{
|
||
$simpleProduct = Product::factory()->create([
|
||
'name' => 'Simple Product',
|
||
'type' => ProductType::SIMPLE,
|
||
]);
|
||
|
||
ProductPrice::factory()->create([
|
||
'purchasable_id' => $simpleProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'unit_amount' => 5000,
|
||
'is_default' => true,
|
||
]);
|
||
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$this->expectException(\Exception::class);
|
||
$this->expectExceptionMessage('not a booking or pool type');
|
||
|
||
Cart::addBooking($simpleProduct, 1, $from, $until);
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_throws_exception_when_insufficient_stock_available_for_booking_period()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
// Claim all stock first
|
||
$this->bookingProduct->claimStock(10, null, $from, $until);
|
||
|
||
$this->expectException(\Exception::class);
|
||
$this->expectExceptionMessage('is not available for the requested period');
|
||
|
||
Cart::addBooking($this->bookingProduct, 5, $from, $until);
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_throws_exception_when_pool_quantity_exceeds_available_single_items()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
// Pool has only 2 single items, trying to book 5
|
||
$this->expectException(\Exception::class);
|
||
$this->expectExceptionMessage('does not have enough available items');
|
||
|
||
Cart::addBooking($this->poolProduct, 5, $from, $until);
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_creates_cart_item_with_correct_from_until_timestamps()
|
||
{
|
||
$from = Carbon::now()->addDays(5)->setTime(14, 30, 0);
|
||
$until = Carbon::now()->addDays(8)->setTime(10, 0, 0);
|
||
|
||
$cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until);
|
||
|
||
$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'));
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_stores_regular_price_correctly()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until);
|
||
|
||
$this->assertNotNull($cartItem->regular_price);
|
||
$this->assertEquals($this->bookingProduct->getCurrentPrice(), $cartItem->regular_price);
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_returns_error_when_pool_quantity_exceeds_available_single_items()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
// Pool has 2 single items, requesting 3
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->poolProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 3,
|
||
'price' => 20.00,
|
||
'from' => $from,
|
||
'until' => $until,
|
||
]);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
$this->assertNotEmpty($errors);
|
||
$this->assertStringContainsString('2', $errors[0]); // Available count
|
||
$this->assertStringContainsString('3', $errors[0]); // Requested count
|
||
$this->assertStringContainsString('Parking Spaces', $errors[0]);
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_passes_with_valid_pool_product_and_timespan()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->poolProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 2, // Exactly matches available single items
|
||
'price' => 20.00,
|
||
'from' => $from,
|
||
'until' => $until,
|
||
]);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
$this->assertEmpty($errors);
|
||
}
|
||
|
||
#[Test]
|
||
public function validate_bookings_handles_multiple_booking_products_in_cart()
|
||
{
|
||
$cart = $this->user->currentCart();
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
|
||
// Valid booking
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->bookingProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 2,
|
||
'price' => 100.00,
|
||
'from' => $from,
|
||
'until' => $until,
|
||
]);
|
||
|
||
// Valid pool booking
|
||
$cart->items()->create([
|
||
'purchasable_id' => $this->poolProduct->id,
|
||
'purchasable_type' => Product::class,
|
||
'quantity' => 1,
|
||
'price' => 20.00,
|
||
'from' => $from,
|
||
'until' => $until,
|
||
]);
|
||
|
||
$errors = Cart::validateBookings();
|
||
|
||
$this->assertEmpty($errors);
|
||
}
|
||
|
||
#[Test]
|
||
public function add_booking_with_parameters_stores_them_correctly()
|
||
{
|
||
$from = Carbon::now()->addDays(1);
|
||
$until = Carbon::now()->addDays(3);
|
||
$parameters = ['special_request' => 'Late checkout', 'vip' => true];
|
||
|
||
$cartItem = Cart::addBooking($this->bookingProduct, 1, $from, $until, $parameters);
|
||
|
||
$this->assertEquals($parameters, $cartItem->parameters);
|
||
}
|
||
}
|