laravel-shop/tests/Feature/Cart/CartDateManagementTest.php

588 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Blax\Shop\Tests\Feature\Cart;
use Blax\Shop\Exceptions\InvalidDateRangeException;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPrice;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PriceType;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use PHPUnit\Framework\Attributes\Test;
class CartDateManagementTest extends TestCase
{
#[Test]
public function it_can_set_cart_dates()
{
$cart = Cart::factory()->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 13". 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 13.
$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);
}
}