BFI tests
This commit is contained in:
parent
7c6b61da45
commit
01c21506b6
|
|
@ -1,6 +0,0 @@
|
|||
{
|
||||
"editor.formatOnSave": false,
|
||||
"[php]": {
|
||||
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
|
||||
},
|
||||
}
|
||||
|
|
@ -75,4 +75,11 @@ class ProductFactory extends Factory
|
|||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function withStocks(int $quantity = 10) : static
|
||||
{
|
||||
return $this->afterCreating(function (Product $product) use ($quantity) {
|
||||
$product->increaseStock($quantity);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class MultiplePurchaseOptions extends Exception {}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
|
||||
namespace Blax\Shop\Exceptions;
|
||||
|
||||
use Exception;
|
||||
|
||||
class NotPurchasable extends Exception {}
|
||||
|
|
@ -168,7 +168,13 @@ class Product extends Model implements Purchasable, Cartable
|
|||
|
||||
public function getAvailableStocksAttribute(): int
|
||||
{
|
||||
return $this->stocks()->available()->sum('quantity') ?? 0;
|
||||
return $this->stocks()
|
||||
->available()
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->sum('quantity') ?? 0;
|
||||
}
|
||||
|
||||
public function purchases(): MorphMany
|
||||
|
|
@ -274,12 +280,16 @@ class Product extends Model implements Purchasable, Cartable
|
|||
?\DateTimeInterface $until = null,
|
||||
?string $note = null
|
||||
): ?\Blax\Shop\Models\ProductStock {
|
||||
|
||||
if (!$this->manage_stock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||
|
||||
return $stockModel::reserve(
|
||||
$this,
|
||||
$quantity,
|
||||
'reservation',
|
||||
$reference,
|
||||
$until,
|
||||
$note
|
||||
|
|
@ -509,4 +519,16 @@ class Product extends Model implements Purchasable, Cartable
|
|||
{
|
||||
return $this->getCurrentPrice();
|
||||
}
|
||||
|
||||
public function reservations()
|
||||
{
|
||||
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
|
||||
|
||||
return $stockModel::reservations()
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')
|
||||
->orWhere('expires_at', '>', now());
|
||||
})
|
||||
->where('product_id', $this->id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -95,12 +95,11 @@ class ProductStock extends Model
|
|||
public static function reserve(
|
||||
Product $product,
|
||||
int $quantity,
|
||||
?string $type = 'reservation',
|
||||
$reference = null,
|
||||
?\DateTimeInterface $until = null,
|
||||
?string $note = null
|
||||
): ?self {
|
||||
return DB::transaction(function () use ($product, $quantity, $type, $reference, $until, $note) {
|
||||
return DB::transaction(function () use ($product, $quantity, $reference, $until, $note) {
|
||||
if (!$product->decreaseStock($quantity)) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -108,7 +107,7 @@ class ProductStock extends Model
|
|||
return self::create([
|
||||
'product_id' => $product->id,
|
||||
'quantity' => $quantity,
|
||||
'type' => $type,
|
||||
'type' => 'reservation',
|
||||
'status' => 'pending',
|
||||
'reference_type' => $reference ? get_class($reference) : null,
|
||||
'reference_id' => $reference?->id,
|
||||
|
|
@ -125,8 +124,6 @@ class ProductStock extends Model
|
|||
}
|
||||
|
||||
return DB::transaction(function () {
|
||||
$this->product->increaseStock($this->quantity);
|
||||
|
||||
$this->status = 'completed';
|
||||
$this->save();
|
||||
|
||||
|
|
@ -212,4 +209,14 @@ class ProductStock extends Model
|
|||
{
|
||||
return $query->where('status', 'completed');
|
||||
}
|
||||
|
||||
public static function scopeAvailableReservations($query)
|
||||
{
|
||||
return $query->where('type', 'reservation')->where('status', 'pending');
|
||||
}
|
||||
|
||||
public static function reservations()
|
||||
{
|
||||
return self::availableReservations();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
namespace Blax\Shop\Traits;
|
||||
|
||||
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Exceptions\NotPurchasable;
|
||||
use Blax\Shop\Models\CartItem;
|
||||
use Blax\Shop\Models\ProductPurchase;
|
||||
use Blax\Shop\Models\Product;
|
||||
|
|
@ -52,26 +54,43 @@ trait HasShoppingCapabilities
|
|||
/**
|
||||
* Purchase a product
|
||||
*
|
||||
* @param Product $product
|
||||
* @param Product|Product $product_or_price
|
||||
* @param int $quantity
|
||||
*
|
||||
* @return ProductPurchase
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function purchase(
|
||||
ProductPrice|string $productPrice,
|
||||
ProductPrice|Product $product_or_price,
|
||||
int $quantity = 1,
|
||||
array|object|null $meta = null
|
||||
): ProductPurchase {
|
||||
|
||||
$productPrice = ($productPrice instanceof ProductPrice)
|
||||
? $productPrice
|
||||
: ProductPrice::findOrFail($productPrice);
|
||||
if ($product_or_price instanceof Product) {
|
||||
$default_prices = $product_or_price->defaultPrice()->count();
|
||||
|
||||
if (!$productPrice?->purchasable?->id) {
|
||||
if ($default_prices === 0) {
|
||||
throw new NotPurchasable("Product has no default price");
|
||||
}
|
||||
|
||||
if ($default_prices > 1) {
|
||||
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to purchase");
|
||||
}
|
||||
|
||||
$price = $product_or_price->defaultPrice()->first();
|
||||
}
|
||||
|
||||
if (!@$price) {
|
||||
$price = ($product_or_price instanceof ProductPrice)
|
||||
? $product_or_price
|
||||
: throw new NotPurchasable;
|
||||
}
|
||||
|
||||
if (!$price?->purchasable?->id) {
|
||||
throw new \Exception("Price does not belong to the specified product");
|
||||
}
|
||||
|
||||
$product = $productPrice->purchasable;
|
||||
$product = $price->purchasable;
|
||||
|
||||
// product must have interface Purchasable
|
||||
if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) {
|
||||
|
|
@ -104,11 +123,8 @@ trait HasShoppingCapabilities
|
|||
'purchaser_type' => get_class($this),
|
||||
'quantity' => $quantity,
|
||||
'status' => 'unpaid',
|
||||
'meta' => array_merge([
|
||||
'price_id' => $productPrice->id,
|
||||
'price' => $productPrice->price,
|
||||
'amount' => $productPrice->price * $quantity,
|
||||
]),
|
||||
'meta' => $meta,
|
||||
'amount' => $price->unit_amount * $quantity,
|
||||
]);
|
||||
|
||||
// Trigger product actions
|
||||
|
|
@ -129,19 +145,55 @@ trait HasShoppingCapabilities
|
|||
return $purchase;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create the current cart for the entity
|
||||
*
|
||||
* @return Cart
|
||||
*/
|
||||
public function currentCart()
|
||||
{
|
||||
return $this->cart()
|
||||
->whereNull('converted_at')
|
||||
->latest()
|
||||
->firstOrCreate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add product to cart
|
||||
*
|
||||
* @param Product|ProductPrice $price
|
||||
* @param Product|ProductPrice $product_or_price
|
||||
* @param int $quantity
|
||||
* @param array $options
|
||||
* @return CartItem
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function addToCart(Product|ProductPrice $price, int $quantity = 1, array $parameters = []): CartItem
|
||||
public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem
|
||||
{
|
||||
return $this->cart()->latest()->firstOrCreate()->addToCart(
|
||||
$price,
|
||||
if ($product_or_price instanceof ProductPrice){
|
||||
$product = $product_or_price->purchasable;
|
||||
|
||||
if ($product instanceof Product) {
|
||||
$product->reserveStock($quantity);
|
||||
}
|
||||
}
|
||||
|
||||
if ($product_or_price instanceof Product) {
|
||||
$product_or_price->reserveStock($quantity);
|
||||
|
||||
$default_prices = $product_or_price->defaultPrice()->count();
|
||||
|
||||
if ($default_prices === 0) {
|
||||
throw new NotPurchasable("Product has no default price");
|
||||
}
|
||||
|
||||
if ($default_prices > 1) {
|
||||
throw new MultiplePurchaseOptions("Product has multiple default prices, please specify a price to add to cart");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return $this->currentCart()->addToCart(
|
||||
$product_or_price,
|
||||
$quantity,
|
||||
$parameters
|
||||
);
|
||||
|
|
@ -205,7 +257,6 @@ trait HasShoppingCapabilities
|
|||
public function getCartTotal(?string $cartId = null): float
|
||||
{
|
||||
return $this->cartItems()->get()->sum(function ($item) {
|
||||
dump('getCurrentPrice',get_class($item->purchasable),$item->purchasable->getCurrentPrice());
|
||||
return ($item->purchasable->getCurrentPrice() ?? 0) * $item->quantity;
|
||||
});
|
||||
}
|
||||
|
|
@ -257,21 +308,24 @@ trait HasShoppingCapabilities
|
|||
$item->delete();
|
||||
}
|
||||
|
||||
$cart = $this->currentCart();
|
||||
$cart->update([
|
||||
'converted_at' => now(),
|
||||
]);
|
||||
|
||||
return $purchases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if entity has purchased a product
|
||||
*
|
||||
* @param Product|int $product
|
||||
* @param Purchasable|int $product
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPurchased($product): bool
|
||||
public function hasPurchased($purchasable): bool
|
||||
{
|
||||
$productId = $product instanceof Product ? $product->id : $product;
|
||||
|
||||
return $this->completedPurchases()
|
||||
->where('product_id', $productId)
|
||||
->where('purchasable_id', $purchasable->id)
|
||||
->exists();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_get_cart_items()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->withPrices()->create();
|
||||
$product2 = Product::factory()->withPrices(2)->create();
|
||||
$product1 = Product::factory()->withStocks(5)->withPrices()->create();
|
||||
$product2 = Product::factory()->withStocks(5)->withPrices(2)->create();
|
||||
|
||||
$this->assertCount(1, $product1->prices);
|
||||
$this->assertCount(2, $product2->prices);
|
||||
|
|
@ -85,7 +85,7 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_update_cart_item_quantity()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->withPrices()->create();
|
||||
$product = Product::factory()->withStocks(5)->withPrices()->create();
|
||||
|
||||
$cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
|
||||
|
||||
|
|
@ -98,7 +98,7 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_remove_item_from_cart()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->withPrices()->create();
|
||||
$product = Product::factory()->withStocks(5)->withPrices()->create();
|
||||
|
||||
$cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
|
||||
|
||||
|
|
@ -113,12 +113,15 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_checkout_cart()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->withPrices()->create();
|
||||
$product2 = Product::factory()->withPrices()->create();
|
||||
$product1 = Product::factory()->withStocks(5)->withPrices(3)->create(['manage_stock' => false]);
|
||||
$product2 = Product::factory()->withStocks(5)->withPrices(3)->create(['manage_stock' => false]);
|
||||
|
||||
$user->addToCart($product1, quantity: 2);
|
||||
$user->addToCart($product2, quantity: 1);
|
||||
|
||||
$product1->update(['manage_stock' => true]);
|
||||
$product2->update(['manage_stock' => true]);
|
||||
|
||||
$this->assertThrows(fn() => $user->checkout(), NotEnoughStockException::class);
|
||||
|
||||
$product1->update(['manage_stock' => false]);
|
||||
|
|
@ -135,8 +138,8 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_get_cart_total()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->withPrices(unit_amount:40)->create();
|
||||
$product2 = Product::factory()->withPrices(unit_amount:60)->create();
|
||||
$product1 = Product::factory()->withStocks()->withPrices(unit_amount:40)->create();
|
||||
$product2 = Product::factory()->withStocks()->withPrices(unit_amount:60)->create();
|
||||
|
||||
$this->assertNotNull($product1->getCurrentPrice());
|
||||
$this->assertNotNull($product2->getCurrentPrice());
|
||||
|
|
@ -153,8 +156,8 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_get_cart_items_count()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->create();
|
||||
$product2 = Product::factory()->create();
|
||||
$product1 = Product::factory()->withStocks()->withPrices()->create();
|
||||
$product2 = Product::factory()->withStocks()->withPrices()->create();
|
||||
|
||||
$user->addToCart($product1, quantity: 3);
|
||||
$user->addToCart($product2, quantity: 2);
|
||||
|
|
@ -168,8 +171,8 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_clear_cart()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->create();
|
||||
$product2 = Product::factory()->create();
|
||||
$product1 = Product::factory()->withStocks()->withPrices()->create();
|
||||
$product2 = Product::factory()->withStocks()->withPrices()->create();
|
||||
|
||||
$user->addToCart($product1, quantity: 1);
|
||||
$user->addToCart($product2, quantity: 1);
|
||||
|
|
@ -185,10 +188,12 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_check_if_product_was_purchased()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$purchasedProduct = Product::factory()->create(['manage_stock' => false]);
|
||||
$notPurchasedProduct = Product::factory()->create();
|
||||
$purchasedProduct = Product::factory()->withPrices()->create(['manage_stock' => false]);
|
||||
$notPurchasedProduct = Product::factory()->withPrices()->create();
|
||||
|
||||
$productPurchase = $user->purchase($purchasedProduct, quantity: 1);
|
||||
$productPurchase->update(['status' => 'completed']);
|
||||
|
||||
$user->purchase($purchasedProduct, quantity: 1);
|
||||
|
||||
$this->assertTrue($user->hasPurchased($purchasedProduct));
|
||||
$this->assertFalse($user->hasPurchased($notPurchasedProduct));
|
||||
|
|
@ -198,15 +203,15 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_can_get_completed_purchases()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product1 = Product::factory()->create(['manage_stock' => false]);
|
||||
$product2 = Product::factory()->create(['manage_stock' => false]);
|
||||
$product3 = Product::factory()->create();
|
||||
$product1 = Product::factory()->withStocks()->withPrices()->create();
|
||||
$product2 = Product::factory()->withStocks()->withPrices()->create();
|
||||
$product3 = Product::factory()->withStocks()->withPrices()->create();
|
||||
|
||||
$user->purchase($product1, quantity: 1);
|
||||
$user->purchase($product2, quantity: 1);
|
||||
$user->addToCart($product3, quantity: 1);
|
||||
|
||||
$completed = $user->completedPurchases;
|
||||
$completed = $user->purchases;
|
||||
|
||||
$this->assertCount(2, $completed);
|
||||
}
|
||||
|
|
@ -215,26 +220,21 @@ class PurchaseFlowTest extends TestCase
|
|||
public function purchase_reduces_stock_when_managed()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 10,
|
||||
]);
|
||||
$product = Product::factory()->withPrices()->create();
|
||||
$product->increaseStock(10);
|
||||
|
||||
$user->purchase($product, quantity: 3);
|
||||
|
||||
$this->assertEquals(7, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals(7, $product->AvailableStocks);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function cannot_purchase_more_than_available_stock()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 5,
|
||||
]);
|
||||
$product = Product::factory()->withPrices()->create();
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectException(NotEnoughStockException::class);
|
||||
|
||||
$user->purchase($product, quantity: 10);
|
||||
}
|
||||
|
|
@ -243,30 +243,23 @@ class PurchaseFlowTest extends TestCase
|
|||
public function adding_to_cart_checks_stock_availability()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 3,
|
||||
]);
|
||||
$product = Product::factory()->withPrices(2)->withStocks(3)->create();
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
||||
$user->addToCart($product, quantity: 5);
|
||||
$this->assertThrows(fn() => $user->addToCart($product, quantity: 5), NotEnoughStockException::class);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function purchase_can_store_metadata()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create(['manage_stock' => false]);
|
||||
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
|
||||
|
||||
$purchase = $user->purchase($product, quantity: 1, options: [
|
||||
'meta' => [
|
||||
'gift_message' => 'Happy Birthday!',
|
||||
'gift_wrap' => true,
|
||||
],
|
||||
$purchase = $user->purchase($product, quantity: 1, meta: [
|
||||
'gift_message' => 'Happy Birthday!',
|
||||
'gift_wrap' => true,
|
||||
]);
|
||||
|
||||
$this->assertEquals('Happy Birthday!', $purchase->meta['gift_message'] ?? null);
|
||||
$this->assertEquals('Happy Birthday!', $purchase->meta->gift_message ?? null);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
@ -295,10 +288,10 @@ class PurchaseFlowTest extends TestCase
|
|||
public function checkout_marks_cart_as_converted()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create(['manage_stock' => false]);
|
||||
$product = Product::factory()->withPrices()->create(['manage_stock' => false]);
|
||||
|
||||
$cartItem = $user->addToCart($product, quantity: 1);
|
||||
$cart = Cart::where('user_id', $user->id)->first();
|
||||
$user->addToCart($product, quantity: 1);
|
||||
$cart = $user->currentCart();
|
||||
|
||||
if ($cart) {
|
||||
$this->assertNull($cart->converted_at);
|
||||
|
|
@ -328,13 +321,14 @@ class PurchaseFlowTest extends TestCase
|
|||
public function purchase_stores_amount_correctly()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$product = Product::factory()->create([
|
||||
'price' => 49.99,
|
||||
$product = Product::factory()->withPrices()->create([
|
||||
'manage_stock' => false,
|
||||
]);
|
||||
|
||||
$purchase = $user->purchase($product, quantity: 2);
|
||||
|
||||
$this->assertEquals(2, $purchase->quantity);
|
||||
$this->assertEquals(0, $purchase->amount_paid);
|
||||
$this->assertGreaterThan(0, $purchase->amount);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,11 @@
|
|||
|
||||
namespace Blax\Shop\Tests\Feature;
|
||||
|
||||
use Blax\Shop\Exceptions\NotEnoughStockException;
|
||||
use Blax\Shop\Models\Product;
|
||||
use Blax\Shop\Models\ProductStock;
|
||||
use Blax\Shop\Tests\TestCase;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class StockManagementTest extends TestCase
|
||||
|
|
@ -14,76 +16,61 @@ class StockManagementTest extends TestCase
|
|||
/** @test */
|
||||
public function it_can_reserve_stock_for_a_product()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()
|
||||
->withStocks(100)
|
||||
->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
$reservation = $product->reserveStock(
|
||||
quantity: 10,
|
||||
type: 'reservation',
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$this->assertNotNull($reservation);
|
||||
$this->assertEquals(10, $reservation->quantity);
|
||||
$this->assertEquals(90, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals(90, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_reserve_more_stock_than_available()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 5,
|
||||
]);
|
||||
$product = Product::factory()
|
||||
->withStocks(5)
|
||||
->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 10,
|
||||
type: 'reservation'
|
||||
);
|
||||
$reservation = null;
|
||||
|
||||
$this->assertThrows(fn() => $reservation = $product->reserveStock(15), NotEnoughStockException::class);
|
||||
|
||||
$this->assertNull($reservation);
|
||||
$this->assertEquals(5, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals(5, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_release_reserved_stock()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()
|
||||
->withStocks(100)
|
||||
->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
$reservation = $product->reserveStock(
|
||||
quantity: 10,
|
||||
type: 'reservation'
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$this->assertEquals(90, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals(90, $product->getAvailableStock());
|
||||
|
||||
$reservation->release();
|
||||
|
||||
$this->assertEquals(100, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals(100, $product->refresh()->getAvailableStock());
|
||||
$this->assertNotNull($reservation->fresh()->released_at);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_check_if_stock_is_pending()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(10)->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 5,
|
||||
type: 'reservation'
|
||||
);
|
||||
$reservation = $product->reserveStock(5);
|
||||
|
||||
$pending = ProductStock::pending()->where('id', $reservation->id)->first();
|
||||
|
||||
|
|
@ -94,16 +81,9 @@ class StockManagementTest extends TestCase
|
|||
/** @test */
|
||||
public function it_can_check_if_stock_is_released()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(50)->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 5,
|
||||
type: 'reservation'
|
||||
);
|
||||
$reservation = $product->reserveStock(5);
|
||||
|
||||
$reservation->release();
|
||||
|
||||
|
|
@ -113,126 +93,79 @@ class StockManagementTest extends TestCase
|
|||
$this->assertNotNull($released->released_at);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_find_expired_reservations()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
|
||||
$expiredReservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 10,
|
||||
type: 'reservation',
|
||||
until: now()->subHour()
|
||||
);
|
||||
|
||||
$activeReservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 5,
|
||||
type: 'reservation',
|
||||
until: now()->addHour()
|
||||
);
|
||||
|
||||
$expired = ProductStock::expired()->get();
|
||||
|
||||
$this->assertTrue($expired->contains($expiredReservation));
|
||||
$this->assertFalse($expired->contains($activeReservation));
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_distinguish_temporary_and_permanent_reservations()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$temporary = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 10,
|
||||
type: 'reservation',
|
||||
until: now()->addHours(2)
|
||||
$permanentReservation = $product->reserveStock(
|
||||
quantity: 10
|
||||
);
|
||||
|
||||
$permanent = ProductStock::reserve(
|
||||
product: $product,
|
||||
$temporaryReservation = $product->reserveStock(
|
||||
quantity: 5,
|
||||
type: 'sold'
|
||||
until: now()->addHours(1)
|
||||
);
|
||||
|
||||
$temporaryReservations = ProductStock::temporary()->get();
|
||||
$permanentReservations = ProductStock::permanent()->get();
|
||||
$this->assertTrue($permanentReservation->isPermanent());
|
||||
$this->assertFalse($permanentReservation->isTemporary());
|
||||
|
||||
$this->assertTrue($temporaryReservations->contains($temporary));
|
||||
$this->assertFalse($temporaryReservations->contains($permanent));
|
||||
$this->assertTrue($permanentReservations->contains($permanent));
|
||||
$this->assertFalse($permanentReservations->contains($temporary));
|
||||
$this->assertTrue($temporaryReservation->isTemporary());
|
||||
$this->assertFalse($temporaryReservation->isPermanent());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_belongs_to_a_product()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(20)->create();
|
||||
|
||||
$stock = ProductStock::reserve(
|
||||
product: $product,
|
||||
quantity: 5,
|
||||
type: 'reservation'
|
||||
);
|
||||
$reservation = $product->reserveStock(5);
|
||||
|
||||
$this->assertEquals($product->id, $stock->product->id);
|
||||
$this->assertInstanceOf(Product::class, $reservation->product);
|
||||
$this->assertEquals($product->id, $reservation->product->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function product_has_many_stock_records()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(30)->create();
|
||||
|
||||
ProductStock::reserve($product, quantity: 10, type: 'reservation');
|
||||
ProductStock::reserve($product, quantity: 5, type: 'reservation');
|
||||
ProductStock::reserve($product, quantity: 3, type: 'sold');
|
||||
$product->increaseStock(10);
|
||||
$product->increaseStock(10);
|
||||
$product->increaseStock(50);
|
||||
|
||||
$this->assertCount(3, $product->fresh()->stocks);
|
||||
$this->assertCount(4, $product->stocks);
|
||||
$this->assertInstanceOf(ProductStock::class, $product->stocks->first());
|
||||
$this->assertEquals(30 + 10 + 10 + 50, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_can_get_active_stock_reservations()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
$active1 = ProductStock::reserve($product, quantity: 10, type: 'reservation');
|
||||
$active2 = ProductStock::reserve($product, quantity: 5, type: 'reservation');
|
||||
$released = ProductStock::reserve($product, quantity: 3, type: 'sold');
|
||||
$released->release();
|
||||
$activeReservation = $product->reserveStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$activeStocks = $product->fresh()->activeStocks;
|
||||
$expiredReservation = $product->reserveStock(
|
||||
quantity: 5,
|
||||
until: now()->subHours(1)
|
||||
);
|
||||
|
||||
$this->assertCount(2, $activeStocks);
|
||||
$this->assertTrue($activeStocks->contains($active1));
|
||||
$this->assertTrue($activeStocks->contains($active2));
|
||||
$this->assertFalse($activeStocks->contains($released));
|
||||
$activeReservations = $product->reservations()->get();
|
||||
|
||||
$this->assertCount(1, $activeReservations);
|
||||
$this->assertEquals($activeReservation->id, $activeReservations->first()->id);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_cannot_release_stock_twice()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
]);
|
||||
$product = Product::factory()->withStocks()->create();
|
||||
|
||||
$reservation = ProductStock::reserve($product, quantity: 10, type: 'reservation');
|
||||
$reservation = $product->reserveStock(5);
|
||||
|
||||
$this->assertTrue($reservation->release());
|
||||
$this->assertFalse($reservation->release());
|
||||
|
|
@ -241,52 +174,37 @@ class StockManagementTest extends TestCase
|
|||
/** @test */
|
||||
public function it_can_store_reservation_note()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 50,
|
||||
]);
|
||||
$product = Product::factory()->withStocks()->create();
|
||||
|
||||
$reservation = ProductStock::reserve(
|
||||
product: $product,
|
||||
$note = "Customer requested to hold this item for 2 days.";
|
||||
|
||||
$reservation = $product->reserveStock(
|
||||
quantity: 5,
|
||||
type: 'reservation',
|
||||
note: 'Reserved for order #12345'
|
||||
note: $note
|
||||
);
|
||||
|
||||
$this->assertEquals('Reserved for order #12345', $reservation->note);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_handles_stock_transactions_atomically()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 10,
|
||||
]);
|
||||
|
||||
// Try to reserve more than available
|
||||
$reservation = ProductStock::reserve($product, quantity: 15, type: 'reservation');
|
||||
|
||||
// Should fail and not change stock
|
||||
$this->assertNull($reservation);
|
||||
$this->assertEquals(10, $product->fresh()->stock_quantity);
|
||||
$this->assertEquals($note, $reservation->note);
|
||||
}
|
||||
|
||||
/** @test */
|
||||
public function it_calculates_available_stock_correctly()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 100,
|
||||
]);
|
||||
$product = Product::factory()->withStocks(100)->create();
|
||||
|
||||
// Reserve some stock
|
||||
ProductStock::reserve($product, quantity: 20, type: 'reservation');
|
||||
ProductStock::reserve($product, quantity: 10, type: 'reservation');
|
||||
$reservation1 = $product->reserveStock(
|
||||
quantity: 10,
|
||||
until: now()->addHours(2)
|
||||
);
|
||||
|
||||
$available = $product->fresh()->stock_quantity;
|
||||
$reservation2 = $product->reserveStock(
|
||||
quantity: 5,
|
||||
until: now()->addHours(1)
|
||||
);
|
||||
|
||||
$this->assertEquals(70, $available);
|
||||
$reservation1->refresh();
|
||||
$reservation2->refresh();
|
||||
|
||||
$this->assertEquals(85, $product->refresh()->getAvailableStock());
|
||||
}
|
||||
|
||||
/** @test */
|
||||
|
|
|
|||
Loading…
Reference in New Issue