From 01c21506b6e40854565345dcc9c7d7b31380c837 Mon Sep 17 00:00:00 2001 From: a6a2f5842 Date: Tue, 25 Nov 2025 12:33:42 +0100 Subject: [PATCH] BFI tests --- .vscode/settings.json | 6 - database/factories/ProductFactory.php | 7 + src/Exceptions/MultiplePurchaseOptions.php | 7 + src/Exceptions/NotPurchasable.php | 7 + src/Models/Product.php | 26 ++- src/Models/ProductStock.php | 17 +- src/Traits/HasShoppingCapabilities.php | 100 +++++++-- tests/Feature/PurchaseFlowTest.php | 90 ++++---- tests/Feature/StockManagementTest.php | 240 +++++++-------------- 9 files changed, 255 insertions(+), 245 deletions(-) delete mode 100644 .vscode/settings.json create mode 100644 src/Exceptions/MultiplePurchaseOptions.php create mode 100644 src/Exceptions/NotPurchasable.php diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 77b3c43..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "editor.formatOnSave": false, - "[php]": { - "editor.defaultFormatter": "bmewburn.vscode-intelephense-client" - }, -} \ No newline at end of file diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 2ac5c14..6a69a92 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -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); + }); + } } diff --git a/src/Exceptions/MultiplePurchaseOptions.php b/src/Exceptions/MultiplePurchaseOptions.php new file mode 100644 index 0000000..429b656 --- /dev/null +++ b/src/Exceptions/MultiplePurchaseOptions.php @@ -0,0 +1,7 @@ +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); + } } diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index 49c82f3..d1ad3f8 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -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(); + } } diff --git a/src/Traits/HasShoppingCapabilities.php b/src/Traits/HasShoppingCapabilities.php index cb5e274..d852cc9 100644 --- a/src/Traits/HasShoppingCapabilities.php +++ b/src/Traits/HasShoppingCapabilities.php @@ -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 - { - return $this->cart()->latest()->firstOrCreate()->addToCart( - $price, + public function addToCart(Product|ProductPrice $product_or_price, int $quantity = 1, array $parameters = []): CartItem + { + 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(); } diff --git a/tests/Feature/PurchaseFlowTest.php b/tests/Feature/PurchaseFlowTest.php index 7b2c8a5..82dab3b 100644 --- a/tests/Feature/PurchaseFlowTest.php +++ b/tests/Feature/PurchaseFlowTest.php @@ -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); } } diff --git a/tests/Feature/StockManagementTest.php b/tests/Feature/StockManagementTest.php index 2af947a..740e7bd 100644 --- a/tests/Feature/StockManagementTest.php +++ b/tests/Feature/StockManagementTest.php @@ -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 */