481 lines
16 KiB
PHP
481 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace Blax\Shop\Tests\Feature\Cart;
|
|
|
|
use Blax\Shop\Contracts\Cartable;
|
|
use Blax\Shop\Contracts\Purchasable;
|
|
use Blax\Shop\Enums\ProductRelationType;
|
|
use Blax\Shop\Enums\ProductType;
|
|
use Blax\Shop\Enums\PurchaseStatus;
|
|
use Blax\Shop\Exceptions\ExceedsMaxPerCartException;
|
|
use Blax\Shop\Exceptions\ExceedsMaxPerUserException;
|
|
use Blax\Shop\Models\Cart;
|
|
use Blax\Shop\Models\Product;
|
|
use Blax\Shop\Models\ProductPrice;
|
|
use Blax\Shop\Models\ProductPurchase;
|
|
use Blax\Shop\Tests\TestCase;
|
|
use Blax\Shop\Traits\IsSimplePurchasable;
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Workbench\App\Models\User;
|
|
|
|
/**
|
|
* Covers the `max_per_cart` and `max_per_user` purchase caps configured on
|
|
* {@see Product} and enforced in {@see Cart::addToCart()}.
|
|
*
|
|
* What we care about:
|
|
* - NULL on both columns keeps the historical behaviour (no cap).
|
|
* - max_per_cart counts total in-cart quantity, not cart items.
|
|
* - max_per_cart respects existing items when a second add bumps over.
|
|
* - max_per_user only applies to identified customers (not guest carts).
|
|
* - max_per_user counts already-placed purchases AND current cart, but
|
|
* skips CART / FAILED rows so the cap can't be bypassed by accumulating
|
|
* dead rows.
|
|
* - Pool products go through the recursive single-unit path; the cap must
|
|
* still fire even when the request is split into per-unit adds.
|
|
* - Different customers under the same product see independent counters.
|
|
*/
|
|
class CartPurchaseLimitsTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
if (!Schema::hasTable('limit_widgets')) {
|
|
Schema::create('limit_widgets', function ($table) {
|
|
$table->uuid('id')->primary();
|
|
$table->string('name');
|
|
$table->timestamps();
|
|
});
|
|
}
|
|
}
|
|
|
|
private function productWithCaps(?int $maxPerCart = null, ?int $maxPerUser = null, int $price = 1000): Product
|
|
{
|
|
$product = Product::factory()->create([
|
|
'name' => 'Capped Product',
|
|
'manage_stock' => false,
|
|
'max_per_cart' => $maxPerCart,
|
|
'max_per_user' => $maxPerUser,
|
|
]);
|
|
|
|
ProductPrice::factory()->create([
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'unit_amount' => $price,
|
|
'currency' => 'EUR',
|
|
'is_default' => true,
|
|
]);
|
|
|
|
return $product->fresh();
|
|
}
|
|
|
|
private function userCart(): array
|
|
{
|
|
$user = User::factory()->create();
|
|
$cart = Cart::create([
|
|
'customer_type' => get_class($user),
|
|
'customer_id' => $user->id,
|
|
]);
|
|
return [$user, $cart];
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// max_per_cart
|
|
// ------------------------------------------------------------------
|
|
|
|
#[Test]
|
|
public function null_caps_keep_unlimited_behaviour(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: null, maxPerUser: null);
|
|
$cart = Cart::create();
|
|
|
|
$cart->addToCart($product, quantity: 50);
|
|
|
|
$this->assertSame(50, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function it_blocks_a_single_add_that_exceeds_max_per_cart(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: 3);
|
|
$cart = Cart::create();
|
|
|
|
$this->expectException(ExceedsMaxPerCartException::class);
|
|
|
|
$cart->addToCart($product, quantity: 4);
|
|
}
|
|
|
|
#[Test]
|
|
public function it_allows_adding_up_to_the_max_per_cart(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: 3);
|
|
$cart = Cart::create();
|
|
|
|
$cart->addToCart($product, quantity: 3);
|
|
|
|
$this->assertSame(3, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function it_blocks_a_second_add_that_would_exceed_max_per_cart(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: 3);
|
|
$cart = Cart::create();
|
|
|
|
$cart->addToCart($product, quantity: 2);
|
|
|
|
$this->expectException(ExceedsMaxPerCartException::class);
|
|
$cart->addToCart($product, quantity: 2); // 2 + 2 = 4 > 3
|
|
}
|
|
|
|
#[Test]
|
|
public function exception_message_reports_the_correct_remaining_quantity(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: 5);
|
|
$cart = Cart::create();
|
|
$cart->addToCart($product, quantity: 4);
|
|
|
|
try {
|
|
$cart->addToCart($product, quantity: 3);
|
|
$this->fail('Expected ExceedsMaxPerCartException was not thrown');
|
|
} catch (ExceedsMaxPerCartException $e) {
|
|
$this->assertStringContainsString('maximum of 5 per cart', $e->getMessage());
|
|
$this->assertStringContainsString('already have 4', $e->getMessage());
|
|
$this->assertStringContainsString('up to 1 more', $e->getMessage());
|
|
}
|
|
}
|
|
|
|
#[Test]
|
|
public function caps_are_per_product_not_global(): void
|
|
{
|
|
$productA = $this->productWithCaps(maxPerCart: 2);
|
|
$productB = $this->productWithCaps(maxPerCart: 2);
|
|
$cart = Cart::create();
|
|
|
|
$cart->addToCart($productA, quantity: 2);
|
|
$cart->addToCart($productB, quantity: 2);
|
|
|
|
$this->assertSame(4, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function adding_via_product_price_still_enforces_the_cap(): void
|
|
{
|
|
// The Cartable can be a ProductPrice — make sure we resolve through
|
|
// .purchasable so a price-driven add hits the same enforcement path
|
|
// as a product-driven one.
|
|
$product = $this->productWithCaps(maxPerCart: 2);
|
|
$price = $product->defaultPrice()->first();
|
|
$cart = Cart::create();
|
|
|
|
$this->expectException(ExceedsMaxPerCartException::class);
|
|
$cart->addToCart($price, quantity: 3);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// max_per_user
|
|
// ------------------------------------------------------------------
|
|
|
|
#[Test]
|
|
public function max_per_user_is_skipped_for_guest_carts(): void
|
|
{
|
|
// Guest cart has no customer_id, so there's no identity to count
|
|
// against — the cap is intentionally bypassed in this case.
|
|
$product = $this->productWithCaps(maxPerUser: 1);
|
|
$guestCart = Cart::create();
|
|
|
|
$guestCart->addToCart($product, quantity: 5);
|
|
|
|
$this->assertSame(5, (int) $guestCart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function max_per_user_blocks_when_in_cart_alone_would_exceed(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerUser: 2);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cart->addToCart($product, quantity: 3);
|
|
}
|
|
|
|
#[Test]
|
|
public function max_per_user_blocks_when_existing_purchases_plus_cart_exceed(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerUser: 3);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::COMPLETED,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 2,
|
|
'amount' => 2000,
|
|
'amount_paid' => 2000,
|
|
]);
|
|
|
|
// Already bought 2/3 — adding 2 more would land on 4 > 3.
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cart->addToCart($product, quantity: 2);
|
|
}
|
|
|
|
#[Test]
|
|
public function max_per_user_allows_the_exact_remaining_quantity(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerUser: 3);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::COMPLETED,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 2,
|
|
'amount' => 2000,
|
|
'amount_paid' => 2000,
|
|
]);
|
|
|
|
$cart->addToCart($product, quantity: 1); // 2 + 1 = 3 == cap
|
|
|
|
$this->assertSame(1, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function pending_and_unpaid_purchases_still_count(): void
|
|
{
|
|
// Critical: the cap can't be bypassed by accumulating unpaid orders.
|
|
$product = $this->productWithCaps(maxPerUser: 2);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::PENDING,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 1,
|
|
'amount' => 1000,
|
|
]);
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::UNPAID,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 1,
|
|
'amount' => 1000,
|
|
]);
|
|
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cart->addToCart($product, quantity: 1);
|
|
}
|
|
|
|
#[Test]
|
|
public function failed_and_cart_status_purchases_are_not_counted(): void
|
|
{
|
|
// Cart rows are not committed purchases; failed rows shouldn't lock
|
|
// the customer out of trying again with a different payment method.
|
|
$product = $this->productWithCaps(maxPerUser: 2);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::CART,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 5,
|
|
'amount' => 5000,
|
|
]);
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::FAILED,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 5,
|
|
'amount' => 5000,
|
|
]);
|
|
|
|
// None of the rows above count, so the customer can still buy 2.
|
|
$cart->addToCart($product, quantity: 2);
|
|
|
|
$this->assertSame(2, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
|
|
#[Test]
|
|
public function caps_are_per_customer_not_global(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerUser: 2);
|
|
|
|
[$userA, $cartA] = $this->userCart();
|
|
[$userB, $cartB] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::COMPLETED,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $userA->id,
|
|
'purchaser_type' => get_class($userA),
|
|
'quantity' => 2,
|
|
'amount' => 2000,
|
|
'amount_paid' => 2000,
|
|
]);
|
|
|
|
// User A is capped, user B is untouched.
|
|
$cartB->addToCart($product, quantity: 2);
|
|
$this->assertSame(2, (int) $cartB->fresh()->items->sum('quantity'));
|
|
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cartA->addToCart($product, quantity: 1);
|
|
}
|
|
|
|
#[Test]
|
|
public function existing_in_cart_combined_with_purchases_is_summed_correctly(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerUser: 5);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
ProductPurchase::create([
|
|
'status' => PurchaseStatus::COMPLETED,
|
|
'purchasable_id' => $product->id,
|
|
'purchasable_type' => Product::class,
|
|
'purchaser_id' => $user->id,
|
|
'purchaser_type' => get_class($user),
|
|
'quantity' => 2,
|
|
'amount' => 2000,
|
|
'amount_paid' => 2000,
|
|
]);
|
|
|
|
$cart->addToCart($product, quantity: 2); // total: 4
|
|
$cart->addToCart($product, quantity: 1); // total: 5 (cap)
|
|
|
|
$this->assertSame(3, (int) $cart->fresh()->items->sum('quantity'));
|
|
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cart->addToCart($product, quantity: 1);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Both caps together
|
|
// ------------------------------------------------------------------
|
|
|
|
#[Test]
|
|
public function the_lower_cap_wins_when_both_are_configured(): void
|
|
{
|
|
// max_per_user=10 is loose, max_per_cart=2 is the binding constraint.
|
|
$product = $this->productWithCaps(maxPerCart: 2, maxPerUser: 10);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
$this->expectException(ExceedsMaxPerCartException::class);
|
|
$cart->addToCart($product, quantity: 3);
|
|
}
|
|
|
|
#[Test]
|
|
public function per_user_can_be_tighter_than_per_cart(): void
|
|
{
|
|
$product = $this->productWithCaps(maxPerCart: 100, maxPerUser: 1);
|
|
[$user, $cart] = $this->userCart();
|
|
|
|
$this->expectException(ExceedsMaxPerUserException::class);
|
|
$cart->addToCart($product, quantity: 2);
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Pool product interaction (recursive per-unit add path)
|
|
// ------------------------------------------------------------------
|
|
|
|
#[Test]
|
|
public function pool_products_respect_max_per_cart(): void
|
|
{
|
|
// Pool products with quantity > 1 go through addToCart() recursively
|
|
// (one unit at a time). The cap must fire on the *initial* call
|
|
// before the recursion expands, otherwise the request would partly
|
|
// succeed and partly fail — a non-atomic add we want to avoid.
|
|
$pool = Product::factory()->create([
|
|
'name' => 'Capped Pool',
|
|
'type' => ProductType::POOL,
|
|
'manage_stock' => false,
|
|
'max_per_cart' => 2,
|
|
]);
|
|
|
|
$single1 = Product::factory()->create([
|
|
'type' => ProductType::BOOKING,
|
|
'manage_stock' => true,
|
|
]);
|
|
$single1->increaseStock(5);
|
|
$single2 = Product::factory()->create([
|
|
'type' => ProductType::BOOKING,
|
|
'manage_stock' => true,
|
|
]);
|
|
$single2->increaseStock(5);
|
|
|
|
ProductPrice::factory()->create([
|
|
'purchasable_id' => $single1->id,
|
|
'purchasable_type' => Product::class,
|
|
'unit_amount' => 1000,
|
|
'currency' => 'EUR',
|
|
'is_default' => true,
|
|
]);
|
|
ProductPrice::factory()->create([
|
|
'purchasable_id' => $single2->id,
|
|
'purchasable_type' => Product::class,
|
|
'unit_amount' => 1000,
|
|
'currency' => 'EUR',
|
|
'is_default' => true,
|
|
]);
|
|
|
|
$pool->productRelations()->attach($single1->id, ['type' => ProductRelationType::SINGLE->value]);
|
|
$pool->productRelations()->attach($single2->id, ['type' => ProductRelationType::SINGLE->value]);
|
|
|
|
$cart = Cart::create();
|
|
|
|
$this->expectException(ExceedsMaxPerCartException::class);
|
|
$cart->addToCart($pool, quantity: 3, parameters: [], from: Carbon::tomorrow(), until: Carbon::tomorrow()->addDays(2));
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Non-Product cartables
|
|
// ------------------------------------------------------------------
|
|
|
|
#[Test]
|
|
public function caps_are_silently_skipped_for_non_product_cartables(): void
|
|
{
|
|
// Host-app models using IsSimplePurchasable don't own these columns,
|
|
// so the enforcement should be a no-op rather than a crash.
|
|
$cart = Cart::create();
|
|
$widget = LimitWidget::create(['name' => 'Hyperion']);
|
|
|
|
$cart->addToCart($widget, quantity: 99);
|
|
|
|
$this->assertSame(99, (int) $cart->fresh()->items->sum('quantity'));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* In-line fixture: smallest valid IsSimplePurchasable host. Used to confirm
|
|
* the cap enforcement is a no-op for non-Product cartables (they don't own
|
|
* the max_per_* columns and shouldn't crash by trying to read them).
|
|
*/
|
|
class LimitWidget extends Model implements Cartable, Purchasable
|
|
{
|
|
use HasUuids;
|
|
use IsSimplePurchasable;
|
|
|
|
protected $table = 'limit_widgets';
|
|
|
|
protected $fillable = ['name'];
|
|
}
|