create(); $from = Carbon::now()->addDays(1); $until = Carbon::now()->addDays(3); $cart->setDates($from, $until, validateAvailability: false); $cart->refresh(); $this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString()); $this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString()); } #[Test] public function it_stores_dates_as_provided_even_if_backwards() { $cart = Cart::factory()->create(); $from = Carbon::now()->addDays(3); $until = Carbon::now()->addDays(1); // Dates are stored as provided (backwards) $cart->setDates($from, $until, validateAvailability: false); $cart->refresh(); // Database stores the dates as provided $this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString()); $this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString()); } #[Test] public function it_can_set_from_date_individually() { $cart = Cart::factory()->create(); $from = Carbon::now()->addDays(1); $cart->setFromDate($from, validateAvailability: false); $cart->refresh(); $this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString()); } #[Test] public function it_can_set_until_date_individually() { $cart = Cart::factory()->create(); $until = Carbon::now()->addDays(3); $cart->setUntilDate($until, validateAvailability: false); $cart->refresh(); $this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString()); } #[Test] public function it_stores_from_date_even_if_after_existing_until_date() { $until = Carbon::now()->addDays(2); $cart = Cart::factory()->create([ 'until' => $until, ]); $from = Carbon::now()->addDays(3); $cart->setFromDate($from, validateAvailability: false); $cart->refresh(); // Database stores the dates as provided (backwards order) $this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString()); $this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString()); } #[Test] public function it_stores_until_date_even_if_before_existing_from_date() { $from = Carbon::now()->addDays(3); $cart = Cart::factory()->create([ 'from' => $from, ]); $until = Carbon::now()->addDays(2); $cart->setUntilDate($until, validateAvailability: false); $cart->refresh(); // Database stores the dates as provided (backwards order) $this->assertEquals($from->toDateTimeString(), $cart->from->toDateTimeString()); $this->assertEquals($until->toDateTimeString(), $cart->until->toDateTimeString()); } #[Test] public function cart_item_uses_own_dates_when_set() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create([ 'from' => Carbon::now()->addDays(1), 'until' => Carbon::now()->addDays(3), ]); $itemFromDate = Carbon::now()->addDays(5); $itemUntilDate = Carbon::now()->addDays(7); $item = $cart->addToCart($product, 1); $item->updateDates($itemFromDate, $itemUntilDate); $this->assertEquals($itemFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString()); $this->assertEquals($itemUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString()); } #[Test] public function cart_item_falls_back_to_cart_dates_when_no_own_dates() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); $cart = Cart::factory()->create([ 'from' => $cartFromDate, 'until' => $cartUntilDate, ]); $item = $cart->addToCart($product, 1); $this->assertEquals($cartFromDate->toDateString(), $item->getEffectiveFromDate()->toDateString()); $this->assertEquals($cartUntilDate->toDateString(), $item->getEffectiveUntilDate()->toDateString()); } #[Test] public function cart_item_returns_null_when_no_dates_available() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $this->assertNull($item->getEffectiveFromDate()); $this->assertNull($item->getEffectiveUntilDate()); $this->assertFalse($item->hasEffectiveDates()); } #[Test] public function cart_item_has_effective_dates_returns_true_when_dates_are_set() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create([ 'from' => Carbon::now()->addDays(1), 'until' => Carbon::now()->addDays(3), ]); $item = $cart->addToCart($product, 1); $this->assertTrue($item->hasEffectiveDates()); } #[Test] public function apply_dates_to_items_sets_dates_on_items_without_dates() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $this->assertNull($item->from); $this->assertNull($item->until); $fromDate = Carbon::now()->addDays(1); $untilDate = Carbon::now()->addDays(3); $cart->setDates($fromDate, $untilDate, validateAvailability: false); $cart->applyDatesToItems(validateAvailability: false); $item->refresh(); $this->assertNotNull($item->from); $this->assertNotNull($item->until); $this->assertEquals($fromDate->toDateString(), $item->from->toDateString()); $this->assertEquals($untilDate->toDateString(), $item->until->toDateString()); } #[Test] public function apply_dates_to_items_does_not_override_existing_item_dates() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $itemFromDate = Carbon::now()->addDays(5); $itemUntilDate = Carbon::now()->addDays(7); $item->updateDates($itemFromDate, $itemUntilDate); $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); // Item dates should remain unchanged when overwrite is false $this->assertEquals($itemFromDate->toDateString(), $item->from->toDateString()); $this->assertEquals($itemUntilDate->toDateString(), $item->until->toDateString()); } #[Test] public function apply_dates_to_items_overwrites_when_overwrite_is_true() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $itemFromDate = Carbon::now()->addDays(5); $itemUntilDate = Carbon::now()->addDays(7); $item->updateDates($itemFromDate, $itemUntilDate); $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: true); $item->refresh(); // Item dates should be overwritten with cart dates when overwrite is true $this->assertEquals($cartFromDate->toDateString(), $item->from->toDateString()); $this->assertEquals($cartUntilDate->toDateString(), $item->until->toDateString()); } #[Test] public function apply_dates_to_items_fills_only_null_from_date_when_overwrite_false() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); // Only set 'until' date on the item, leave 'from' as null $itemUntilDate = Carbon::now()->addDays(7); $item->until = $itemUntilDate; $item->save(); $cartFromDate = Carbon::now()->addDays(1); $cartUntilDate = Carbon::now()->addDays(3); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); // 'from' should be filled from cart, 'until' should remain unchanged $this->assertEquals($cartFromDate->toDateString(), $item->from->toDateString()); $this->assertEquals($itemUntilDate->toDateString(), $item->until->toDateString()); } #[Test] public function apply_dates_to_items_fills_only_null_until_date_when_overwrite_false() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); // Only set 'from' date on the item, leave 'until' as null $itemFromDate = Carbon::now()->addDays(1); $item->from = $itemFromDate; $item->save(); $cartFromDate = Carbon::now()->addDays(5); $cartUntilDate = Carbon::now()->addDays(7); $cart->setDates($cartFromDate, $cartUntilDate, validateAvailability: false, overwrite_item_dates: false); $cart->applyDatesToItems(validateAvailability: false, overwrite: false); $item->refresh(); // 'from' should remain unchanged, 'until' should be filled from cart $this->assertEquals($itemFromDate->toDateString(), $item->from->toDateString()); $this->assertEquals($cartUntilDate->toDateString(), $item->until->toDateString()); } #[Test] public function is_ready_to_checkout_uses_cart_fallback_dates() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create([ 'from' => Carbon::now()->addDays(1), 'until' => Carbon::now()->addDays(3), ]); $item = $cart->addToCart($product, 1); // Item should be ready because it uses cart dates $this->assertTrue($item->is_ready_to_checkout); } #[Test] public function cart_item_set_from_date_throws_invalid_date_range_exception() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $item->setUntilDate(Carbon::now()->addDays(2)); $this->expectException(InvalidDateRangeException::class); $item->setFromDate(Carbon::now()->addDays(3)); } #[Test] public function cart_item_set_until_date_throws_invalid_date_range_exception() { $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => false, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $item->setFromDate(Carbon::now()->addDays(3)); $this->expectException(InvalidDateRangeException::class); $item->setUntilDate(Carbon::now()->addDays(2)); } #[Test] public function validate_date_availability_marks_items_unavailable_when_product_not_available() { // Single-unit booking product. Stock is real (one INCREASE entry in // the ledger via withStocks) so addToCart can succeed pre-dates; // the conflict that the validation must catch is simulated by an // existing CLAIMED entry on the ledger — i.e. "a prior checkout // already locked this unit for days 1–3". Claims are created at // checkout time in real life, not by setting cart dates, so we // place one directly here to exercise the date-overlap path. $product = Product::factory()->withStocks(1)->create([ 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); // Pre-existing claim that locks the unit for days 1–3. $product->claimStock( quantity: 1, reference: null, from: Carbon::now()->addDays(1), until: Carbon::now()->addDays(3), note: 'Test: existing booking blocks the single unit', ); // Customer cart tries to book the same product for overlapping dates. // addToCart succeeds (pool capacity = 1, no items yet); setDates // must NOT throw, but must mark the booking item unavailable. $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); $cart->setDates( Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true, ); $item->refresh(); $this->assertNull($item->price, 'Unavailable item should have null price'); $this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout'); } #[Test] public function apply_dates_to_items_marks_items_unavailable_when_product_not_available() { $product = Product::factory()->withStocks(1)->create([ 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); // Create cart WITHOUT dates first (so addToCart doesn't validate) $cart = Cart::factory()->create(); // Add item that would exceed available stock (qty=2 but stock=1) // This succeeds because cart has no dates yet, so no availability validation $item = $cart->addToCart($product, 2); // Now set dates on the cart with validation enabled // This triggers applyDatesToItems which should mark items as unavailable // rather than throwing an exception $cart->setDates( Carbon::now()->addDays(1), Carbon::now()->addDays(3), validateAvailability: true ); // Item should be marked as unavailable (null price) $item->refresh(); $this->assertNull($item->price, 'Unavailable item should have null price'); $this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout'); } #[Test] public function can_skip_validation_when_setting_dates() { // No `->withStocks(...)` — manage_stock=true with no ledger entries // means getAvailableStock() returns 0. Same intent as the old // 'stock_quantity' => 0. $product = Product::factory()->create([ 'type' => ProductType::BOOKING, 'manage_stock' => true, ]); $price = ProductPrice::factory()->create([ 'purchasable_id' => $product->id, 'purchasable_type' => Product::class, 'type' => PriceType::RECURRING, 'is_default' => true, ]); $cart = Cart::factory()->create(); $item = $cart->addToCart($product, 1); // Should not throw exception when validation is disabled $cart->setDates( Carbon::now()->addDays(1), Carbon::now()->addDays(3), validateAvailability: false ); $this->assertNotNull($cart->from); $this->assertNotNull($cart->until); } }