BFI tests

This commit is contained in:
a6a2f5842 2025-11-25 12:33:42 +01:00
parent 7c6b61da45
commit 01c21506b6
9 changed files with 255 additions and 245 deletions

View File

@ -1,6 +0,0 @@
{
"editor.formatOnSave": false,
"[php]": {
"editor.defaultFormatter": "bmewburn.vscode-intelephense-client"
},
}

View File

@ -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);
});
}
} }

View File

@ -0,0 +1,7 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class MultiplePurchaseOptions extends Exception {}

View File

@ -0,0 +1,7 @@
<?php
namespace Blax\Shop\Exceptions;
use Exception;
class NotPurchasable extends Exception {}

View File

@ -168,7 +168,13 @@ class Product extends Model implements Purchasable, Cartable
public function getAvailableStocksAttribute(): int 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 public function purchases(): MorphMany
@ -274,12 +280,16 @@ class Product extends Model implements Purchasable, Cartable
?\DateTimeInterface $until = null, ?\DateTimeInterface $until = null,
?string $note = null ?string $note = null
): ?\Blax\Shop\Models\ProductStock { ): ?\Blax\Shop\Models\ProductStock {
if (!$this->manage_stock) {
return null;
}
$stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock'); $stockModel = config('shop.models.product_stock', 'Blax\Shop\Models\ProductStock');
return $stockModel::reserve( return $stockModel::reserve(
$this, $this,
$quantity, $quantity,
'reservation',
$reference, $reference,
$until, $until,
$note $note
@ -509,4 +519,16 @@ class Product extends Model implements Purchasable, Cartable
{ {
return $this->getCurrentPrice(); 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);
}
} }

View File

@ -95,12 +95,11 @@ class ProductStock extends Model
public static function reserve( public static function reserve(
Product $product, Product $product,
int $quantity, int $quantity,
?string $type = 'reservation',
$reference = null, $reference = null,
?\DateTimeInterface $until = null, ?\DateTimeInterface $until = null,
?string $note = null ?string $note = null
): ?self { ): ?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)) { if (!$product->decreaseStock($quantity)) {
return null; return null;
} }
@ -108,7 +107,7 @@ class ProductStock extends Model
return self::create([ return self::create([
'product_id' => $product->id, 'product_id' => $product->id,
'quantity' => $quantity, 'quantity' => $quantity,
'type' => $type, 'type' => 'reservation',
'status' => 'pending', 'status' => 'pending',
'reference_type' => $reference ? get_class($reference) : null, 'reference_type' => $reference ? get_class($reference) : null,
'reference_id' => $reference?->id, 'reference_id' => $reference?->id,
@ -125,8 +124,6 @@ class ProductStock extends Model
} }
return DB::transaction(function () { return DB::transaction(function () {
$this->product->increaseStock($this->quantity);
$this->status = 'completed'; $this->status = 'completed';
$this->save(); $this->save();
@ -212,4 +209,14 @@ class ProductStock extends Model
{ {
return $query->where('status', 'completed'); 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();
}
} }

View File

@ -2,7 +2,9 @@
namespace Blax\Shop\Traits; namespace Blax\Shop\Traits;
use Blax\Shop\Exceptions\MultiplePurchaseOptions;
use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Exceptions\NotPurchasable;
use Blax\Shop\Models\CartItem; use Blax\Shop\Models\CartItem;
use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
@ -52,26 +54,43 @@ trait HasShoppingCapabilities
/** /**
* Purchase a product * Purchase a product
* *
* @param Product $product * @param Product|Product $product_or_price
* @param int $quantity * @param int $quantity
* *
* @return ProductPurchase * @return ProductPurchase
* @throws \Exception * @throws \Exception
*/ */
public function purchase( public function purchase(
ProductPrice|string $productPrice, ProductPrice|Product $product_or_price,
int $quantity = 1, int $quantity = 1,
array|object|null $meta = null
): ProductPurchase { ): ProductPurchase {
$productPrice = ($productPrice instanceof ProductPrice) if ($product_or_price instanceof Product) {
? $productPrice $default_prices = $product_or_price->defaultPrice()->count();
: ProductPrice::findOrFail($productPrice);
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"); throw new \Exception("Price does not belong to the specified product");
} }
$product = $productPrice->purchasable; $product = $price->purchasable;
// product must have interface Purchasable // product must have interface Purchasable
if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) { if (!in_array('Blax\Shop\Contracts\Purchasable', class_implements($product))) {
@ -104,11 +123,8 @@ trait HasShoppingCapabilities
'purchaser_type' => get_class($this), 'purchaser_type' => get_class($this),
'quantity' => $quantity, 'quantity' => $quantity,
'status' => 'unpaid', 'status' => 'unpaid',
'meta' => array_merge([ 'meta' => $meta,
'price_id' => $productPrice->id, 'amount' => $price->unit_amount * $quantity,
'price' => $productPrice->price,
'amount' => $productPrice->price * $quantity,
]),
]); ]);
// Trigger product actions // Trigger product actions
@ -129,19 +145,55 @@ trait HasShoppingCapabilities
return $purchase; 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 * Add product to cart
* *
* @param Product|ProductPrice $price * @param Product|ProductPrice $product_or_price
* @param int $quantity * @param int $quantity
* @param array $options * @param array $options
* @return CartItem * @return CartItem
* @throws \Exception * @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( if ($product_or_price instanceof ProductPrice){
$price, $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, $quantity,
$parameters $parameters
); );
@ -205,7 +257,6 @@ trait HasShoppingCapabilities
public function getCartTotal(?string $cartId = null): float public function getCartTotal(?string $cartId = null): float
{ {
return $this->cartItems()->get()->sum(function ($item) { return $this->cartItems()->get()->sum(function ($item) {
dump('getCurrentPrice',get_class($item->purchasable),$item->purchasable->getCurrentPrice());
return ($item->purchasable->getCurrentPrice() ?? 0) * $item->quantity; return ($item->purchasable->getCurrentPrice() ?? 0) * $item->quantity;
}); });
} }
@ -257,21 +308,24 @@ trait HasShoppingCapabilities
$item->delete(); $item->delete();
} }
$cart = $this->currentCart();
$cart->update([
'converted_at' => now(),
]);
return $purchases; return $purchases;
} }
/** /**
* Check if entity has purchased a product * Check if entity has purchased a product
* *
* @param Product|int $product * @param Purchasable|int $product
* @return bool * @return bool
*/ */
public function hasPurchased($product): bool public function hasPurchased($purchasable): bool
{ {
$productId = $product instanceof Product ? $product->id : $product;
return $this->completedPurchases() return $this->completedPurchases()
->where('product_id', $productId) ->where('purchasable_id', $purchasable->id)
->exists(); ->exists();
} }

View File

@ -67,8 +67,8 @@ class PurchaseFlowTest extends TestCase
public function user_can_get_cart_items() public function user_can_get_cart_items()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->withPrices()->create(); $product1 = Product::factory()->withStocks(5)->withPrices()->create();
$product2 = Product::factory()->withPrices(2)->create(); $product2 = Product::factory()->withStocks(5)->withPrices(2)->create();
$this->assertCount(1, $product1->prices); $this->assertCount(1, $product1->prices);
$this->assertCount(2, $product2->prices); $this->assertCount(2, $product2->prices);
@ -85,7 +85,7 @@ class PurchaseFlowTest extends TestCase
public function user_can_update_cart_item_quantity() public function user_can_update_cart_item_quantity()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->withPrices()->create(); $product = Product::factory()->withStocks(5)->withPrices()->create();
$cartItem = $user->addToCart($product->prices()->first(), quantity: 1); $cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
@ -98,7 +98,7 @@ class PurchaseFlowTest extends TestCase
public function user_can_remove_item_from_cart() public function user_can_remove_item_from_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->withPrices()->create(); $product = Product::factory()->withStocks(5)->withPrices()->create();
$cartItem = $user->addToCart($product->prices()->first(), quantity: 1); $cartItem = $user->addToCart($product->prices()->first(), quantity: 1);
@ -113,12 +113,15 @@ class PurchaseFlowTest extends TestCase
public function user_can_checkout_cart() public function user_can_checkout_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->withPrices()->create(); $product1 = Product::factory()->withStocks(5)->withPrices(3)->create(['manage_stock' => false]);
$product2 = Product::factory()->withPrices()->create(); $product2 = Product::factory()->withStocks(5)->withPrices(3)->create(['manage_stock' => false]);
$user->addToCart($product1, quantity: 2); $user->addToCart($product1, quantity: 2);
$user->addToCart($product2, quantity: 1); $user->addToCart($product2, quantity: 1);
$product1->update(['manage_stock' => true]);
$product2->update(['manage_stock' => true]);
$this->assertThrows(fn() => $user->checkout(), NotEnoughStockException::class); $this->assertThrows(fn() => $user->checkout(), NotEnoughStockException::class);
$product1->update(['manage_stock' => false]); $product1->update(['manage_stock' => false]);
@ -135,8 +138,8 @@ class PurchaseFlowTest extends TestCase
public function user_can_get_cart_total() public function user_can_get_cart_total()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->withPrices(unit_amount:40)->create(); $product1 = Product::factory()->withStocks()->withPrices(unit_amount:40)->create();
$product2 = Product::factory()->withPrices(unit_amount:60)->create(); $product2 = Product::factory()->withStocks()->withPrices(unit_amount:60)->create();
$this->assertNotNull($product1->getCurrentPrice()); $this->assertNotNull($product1->getCurrentPrice());
$this->assertNotNull($product2->getCurrentPrice()); $this->assertNotNull($product2->getCurrentPrice());
@ -153,8 +156,8 @@ class PurchaseFlowTest extends TestCase
public function user_can_get_cart_items_count() public function user_can_get_cart_items_count()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->create(); $product1 = Product::factory()->withStocks()->withPrices()->create();
$product2 = Product::factory()->create(); $product2 = Product::factory()->withStocks()->withPrices()->create();
$user->addToCart($product1, quantity: 3); $user->addToCart($product1, quantity: 3);
$user->addToCart($product2, quantity: 2); $user->addToCart($product2, quantity: 2);
@ -168,8 +171,8 @@ class PurchaseFlowTest extends TestCase
public function user_can_clear_cart() public function user_can_clear_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->create(); $product1 = Product::factory()->withStocks()->withPrices()->create();
$product2 = Product::factory()->create(); $product2 = Product::factory()->withStocks()->withPrices()->create();
$user->addToCart($product1, quantity: 1); $user->addToCart($product1, quantity: 1);
$user->addToCart($product2, quantity: 1); $user->addToCart($product2, quantity: 1);
@ -185,10 +188,12 @@ class PurchaseFlowTest extends TestCase
public function user_can_check_if_product_was_purchased() public function user_can_check_if_product_was_purchased()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$purchasedProduct = Product::factory()->create(['manage_stock' => false]); $purchasedProduct = Product::factory()->withPrices()->create(['manage_stock' => false]);
$notPurchasedProduct = Product::factory()->create(); $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->assertTrue($user->hasPurchased($purchasedProduct));
$this->assertFalse($user->hasPurchased($notPurchasedProduct)); $this->assertFalse($user->hasPurchased($notPurchasedProduct));
@ -198,15 +203,15 @@ class PurchaseFlowTest extends TestCase
public function user_can_get_completed_purchases() public function user_can_get_completed_purchases()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product1 = Product::factory()->create(['manage_stock' => false]); $product1 = Product::factory()->withStocks()->withPrices()->create();
$product2 = Product::factory()->create(['manage_stock' => false]); $product2 = Product::factory()->withStocks()->withPrices()->create();
$product3 = Product::factory()->create(); $product3 = Product::factory()->withStocks()->withPrices()->create();
$user->purchase($product1, quantity: 1); $user->purchase($product1, quantity: 1);
$user->purchase($product2, quantity: 1); $user->purchase($product2, quantity: 1);
$user->addToCart($product3, quantity: 1); $user->addToCart($product3, quantity: 1);
$completed = $user->completedPurchases; $completed = $user->purchases;
$this->assertCount(2, $completed); $this->assertCount(2, $completed);
} }
@ -215,26 +220,21 @@ class PurchaseFlowTest extends TestCase
public function purchase_reduces_stock_when_managed() public function purchase_reduces_stock_when_managed()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices()->create();
'manage_stock' => true, $product->increaseStock(10);
'stock_quantity' => 10,
]);
$user->purchase($product, quantity: 3); $user->purchase($product, quantity: 3);
$this->assertEquals(7, $product->fresh()->stock_quantity); $this->assertEquals(7, $product->AvailableStocks);
} }
/** @test */ /** @test */
public function cannot_purchase_more_than_available_stock() public function cannot_purchase_more_than_available_stock()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices()->create();
'manage_stock' => true,
'stock_quantity' => 5,
]);
$this->expectException(\Exception::class); $this->expectException(NotEnoughStockException::class);
$user->purchase($product, quantity: 10); $user->purchase($product, quantity: 10);
} }
@ -243,30 +243,23 @@ class PurchaseFlowTest extends TestCase
public function adding_to_cart_checks_stock_availability() public function adding_to_cart_checks_stock_availability()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices(2)->withStocks(3)->create();
'manage_stock' => true,
'stock_quantity' => 3,
]);
$this->expectException(\Exception::class); $this->assertThrows(fn() => $user->addToCart($product, quantity: 5), NotEnoughStockException::class);
$user->addToCart($product, quantity: 5);
} }
/** @test */ /** @test */
public function purchase_can_store_metadata() public function purchase_can_store_metadata()
{ {
$user = User::factory()->create(); $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: [ $purchase = $user->purchase($product, quantity: 1, meta: [
'meta' => [ 'gift_message' => 'Happy Birthday!',
'gift_message' => 'Happy Birthday!', 'gift_wrap' => true,
'gift_wrap' => true,
],
]); ]);
$this->assertEquals('Happy Birthday!', $purchase->meta['gift_message'] ?? null); $this->assertEquals('Happy Birthday!', $purchase->meta->gift_message ?? null);
} }
/** @test */ /** @test */
@ -295,10 +288,10 @@ class PurchaseFlowTest extends TestCase
public function checkout_marks_cart_as_converted() public function checkout_marks_cart_as_converted()
{ {
$user = User::factory()->create(); $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); $user->addToCart($product, quantity: 1);
$cart = Cart::where('user_id', $user->id)->first(); $cart = $user->currentCart();
if ($cart) { if ($cart) {
$this->assertNull($cart->converted_at); $this->assertNull($cart->converted_at);
@ -328,13 +321,14 @@ class PurchaseFlowTest extends TestCase
public function purchase_stores_amount_correctly() public function purchase_stores_amount_correctly()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
$product = Product::factory()->create([ $product = Product::factory()->withPrices()->create([
'price' => 49.99,
'manage_stock' => false, 'manage_stock' => false,
]); ]);
$purchase = $user->purchase($product, quantity: 2); $purchase = $user->purchase($product, quantity: 2);
$this->assertEquals(2, $purchase->quantity);
$this->assertEquals(0, $purchase->amount_paid);
$this->assertGreaterThan(0, $purchase->amount); $this->assertGreaterThan(0, $purchase->amount);
} }
} }

View File

@ -2,9 +2,11 @@
namespace Blax\Shop\Tests\Feature; namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Exceptions\NotEnoughStockException;
use Blax\Shop\Models\Product; use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock; use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase; use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
class StockManagementTest extends TestCase class StockManagementTest extends TestCase
@ -14,76 +16,61 @@ class StockManagementTest extends TestCase
/** @test */ /** @test */
public function it_can_reserve_stock_for_a_product() public function it_can_reserve_stock_for_a_product()
{ {
$product = Product::factory()->create([ $product = Product::factory()
'manage_stock' => true, ->withStocks(100)
'stock_quantity' => 100, ->create();
]);
$reservation = ProductStock::reserve( $reservation = $product->reserveStock(
product: $product,
quantity: 10, quantity: 10,
type: 'reservation',
until: now()->addHours(2) until: now()->addHours(2)
); );
$this->assertNotNull($reservation); $this->assertNotNull($reservation);
$this->assertEquals(10, $reservation->quantity); $this->assertEquals(10, $reservation->quantity);
$this->assertEquals(90, $product->fresh()->stock_quantity); $this->assertEquals(90, $product->getAvailableStock());
} }
/** @test */ /** @test */
public function it_cannot_reserve_more_stock_than_available() public function it_cannot_reserve_more_stock_than_available()
{ {
$product = Product::factory()->create([ $product = Product::factory()
'manage_stock' => true, ->withStocks(5)
'stock_quantity' => 5, ->create();
]);
$reservation = ProductStock::reserve( $reservation = null;
product: $product,
quantity: 10, $this->assertThrows(fn() => $reservation = $product->reserveStock(15), NotEnoughStockException::class);
type: 'reservation'
);
$this->assertNull($reservation); $this->assertNull($reservation);
$this->assertEquals(5, $product->fresh()->stock_quantity); $this->assertEquals(5, $product->getAvailableStock());
} }
/** @test */ /** @test */
public function it_can_release_reserved_stock() public function it_can_release_reserved_stock()
{ {
$product = Product::factory()->create([ $product = Product::factory()
'manage_stock' => true, ->withStocks(100)
'stock_quantity' => 100, ->create();
]);
$reservation = ProductStock::reserve( $reservation = $product->reserveStock(
product: $product,
quantity: 10, quantity: 10,
type: 'reservation' until: now()->addHours(2)
); );
$this->assertEquals(90, $product->fresh()->stock_quantity); $this->assertEquals(90, $product->getAvailableStock());
$reservation->release(); $reservation->release();
$this->assertEquals(100, $product->fresh()->stock_quantity); $this->assertEquals(100, $product->refresh()->getAvailableStock());
$this->assertNotNull($reservation->fresh()->released_at); $this->assertNotNull($reservation->fresh()->released_at);
} }
/** @test */ /** @test */
public function it_can_check_if_stock_is_pending() public function it_can_check_if_stock_is_pending()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(10)->create();
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve( $reservation = $product->reserveStock(5);
product: $product,
quantity: 5,
type: 'reservation'
);
$pending = ProductStock::pending()->where('id', $reservation->id)->first(); $pending = ProductStock::pending()->where('id', $reservation->id)->first();
@ -94,16 +81,9 @@ class StockManagementTest extends TestCase
/** @test */ /** @test */
public function it_can_check_if_stock_is_released() public function it_can_check_if_stock_is_released()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(50)->create();
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve( $reservation = $product->reserveStock(5);
product: $product,
quantity: 5,
type: 'reservation'
);
$reservation->release(); $reservation->release();
@ -113,126 +93,79 @@ class StockManagementTest extends TestCase
$this->assertNotNull($released->released_at); $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 */ /** @test */
public function it_can_distinguish_temporary_and_permanent_reservations() public function it_can_distinguish_temporary_and_permanent_reservations()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(100)->create();
'manage_stock' => true,
'stock_quantity' => 100,
]);
$temporary = ProductStock::reserve( $permanentReservation = $product->reserveStock(
product: $product, quantity: 10
quantity: 10,
type: 'reservation',
until: now()->addHours(2)
); );
$permanent = ProductStock::reserve( $temporaryReservation = $product->reserveStock(
product: $product,
quantity: 5, quantity: 5,
type: 'sold' until: now()->addHours(1)
); );
$temporaryReservations = ProductStock::temporary()->get(); $this->assertTrue($permanentReservation->isPermanent());
$permanentReservations = ProductStock::permanent()->get(); $this->assertFalse($permanentReservation->isTemporary());
$this->assertTrue($temporaryReservations->contains($temporary)); $this->assertTrue($temporaryReservation->isTemporary());
$this->assertFalse($temporaryReservations->contains($permanent)); $this->assertFalse($temporaryReservation->isPermanent());
$this->assertTrue($permanentReservations->contains($permanent));
$this->assertFalse($permanentReservations->contains($temporary));
} }
/** @test */ /** @test */
public function it_belongs_to_a_product() public function it_belongs_to_a_product()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(20)->create();
'manage_stock' => true,
'stock_quantity' => 50,
]);
$stock = ProductStock::reserve( $reservation = $product->reserveStock(5);
product: $product,
quantity: 5,
type: 'reservation'
);
$this->assertEquals($product->id, $stock->product->id); $this->assertInstanceOf(Product::class, $reservation->product);
$this->assertEquals($product->id, $reservation->product->id);
} }
/** @test */ /** @test */
public function product_has_many_stock_records() public function product_has_many_stock_records()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(30)->create();
'manage_stock' => true,
'stock_quantity' => 100,
]);
ProductStock::reserve($product, quantity: 10, type: 'reservation'); $product->increaseStock(10);
ProductStock::reserve($product, quantity: 5, type: 'reservation'); $product->increaseStock(10);
ProductStock::reserve($product, quantity: 3, type: 'sold'); $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 */ /** @test */
public function it_can_get_active_stock_reservations() public function it_can_get_active_stock_reservations()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(100)->create();
'manage_stock' => true,
'stock_quantity' => 100,
]);
$active1 = ProductStock::reserve($product, quantity: 10, type: 'reservation'); $activeReservation = $product->reserveStock(
$active2 = ProductStock::reserve($product, quantity: 5, type: 'reservation'); quantity: 10,
$released = ProductStock::reserve($product, quantity: 3, type: 'sold'); until: now()->addHours(2)
$released->release(); );
$activeStocks = $product->fresh()->activeStocks; $expiredReservation = $product->reserveStock(
quantity: 5,
until: now()->subHours(1)
);
$this->assertCount(2, $activeStocks); $activeReservations = $product->reservations()->get();
$this->assertTrue($activeStocks->contains($active1));
$this->assertTrue($activeStocks->contains($active2)); $this->assertCount(1, $activeReservations);
$this->assertFalse($activeStocks->contains($released)); $this->assertEquals($activeReservation->id, $activeReservations->first()->id);
} }
/** @test */ /** @test */
public function it_cannot_release_stock_twice() public function it_cannot_release_stock_twice()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks()->create();
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve($product, quantity: 10, type: 'reservation'); $reservation = $product->reserveStock(5);
$this->assertTrue($reservation->release()); $this->assertTrue($reservation->release());
$this->assertFalse($reservation->release()); $this->assertFalse($reservation->release());
@ -241,52 +174,37 @@ class StockManagementTest extends TestCase
/** @test */ /** @test */
public function it_can_store_reservation_note() public function it_can_store_reservation_note()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks()->create();
'manage_stock' => true,
'stock_quantity' => 50,
]);
$reservation = ProductStock::reserve( $note = "Customer requested to hold this item for 2 days.";
product: $product,
$reservation = $product->reserveStock(
quantity: 5, quantity: 5,
type: 'reservation', note: $note
note: 'Reserved for order #12345'
); );
$this->assertEquals('Reserved for order #12345', $reservation->note); $this->assertEquals($note, $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);
} }
/** @test */ /** @test */
public function it_calculates_available_stock_correctly() public function it_calculates_available_stock_correctly()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(100)->create();
'manage_stock' => true,
'stock_quantity' => 100,
]);
// Reserve some stock $reservation1 = $product->reserveStock(
ProductStock::reserve($product, quantity: 20, type: 'reservation'); quantity: 10,
ProductStock::reserve($product, quantity: 10, type: 'reservation'); 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 */ /** @test */