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); // Price should be: price_per_day (10000 cents = 100 dollars) × days (3) = 30000 cents per unit // Total should be: 30000 × quantity (2) = 60000 cents $expectedPricePerUnit = 10000 * $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); } }