2025-11-21 10:49:41 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace Blax\Shop\Tests\Feature;
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
use Blax\Shop\Exceptions\NotEnoughStockException;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Blax\Shop\Models\Product;
|
|
|
|
|
use Blax\Shop\Models\ProductStock;
|
|
|
|
|
use Blax\Shop\Tests\TestCase;
|
2025-11-25 11:33:42 +00:00
|
|
|
use Carbon\Carbon;
|
2025-11-21 10:49:41 +00:00
|
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
|
|
|
|
|
|
|
|
class StockManagementTest extends TestCase
|
|
|
|
|
{
|
|
|
|
|
use RefreshDatabase;
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_reserve_stock_for_a_product()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()
|
|
|
|
|
->withStocks(100)
|
|
|
|
|
->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(
|
2025-11-21 10:49:41 +00:00
|
|
|
quantity: 10,
|
|
|
|
|
until: now()->addHours(2)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$this->assertNotNull($reservation);
|
|
|
|
|
$this->assertEquals(10, $reservation->quantity);
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals(90, $product->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_cannot_reserve_more_stock_than_available()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()
|
|
|
|
|
->withStocks(5)
|
|
|
|
|
->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = null;
|
|
|
|
|
|
|
|
|
|
$this->assertThrows(fn() => $reservation = $product->reserveStock(15), NotEnoughStockException::class);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$this->assertNull($reservation);
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals(5, $product->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_release_reserved_stock()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()
|
|
|
|
|
->withStocks(100)
|
|
|
|
|
->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(
|
2025-11-21 10:49:41 +00:00
|
|
|
quantity: 10,
|
2025-11-25 11:33:42 +00:00
|
|
|
until: now()->addHours(2)
|
2025-11-21 10:49:41 +00:00
|
|
|
);
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals(90, $product->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$reservation->release();
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals(100, $product->refresh()->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
$this->assertNotNull($reservation->fresh()->released_at);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_check_if_stock_is_pending()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(10)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(5);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$pending = ProductStock::pending()->where('id', $reservation->id)->first();
|
|
|
|
|
|
|
|
|
|
$this->assertNotNull($pending);
|
|
|
|
|
$this->assertNull($pending->released_at);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_check_if_stock_is_released()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(50)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(5);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$reservation->release();
|
|
|
|
|
|
|
|
|
|
$released = ProductStock::released()->where('id', $reservation->id)->first();
|
|
|
|
|
|
|
|
|
|
$this->assertNotNull($released);
|
|
|
|
|
$this->assertNotNull($released->released_at);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_distinguish_temporary_and_permanent_reservations()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(100)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$permanentReservation = $product->reserveStock(
|
|
|
|
|
quantity: 10
|
2025-11-21 10:49:41 +00:00
|
|
|
);
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$temporaryReservation = $product->reserveStock(
|
2025-11-21 10:49:41 +00:00
|
|
|
quantity: 5,
|
2025-11-25 11:33:42 +00:00
|
|
|
until: now()->addHours(1)
|
2025-11-21 10:49:41 +00:00
|
|
|
);
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertTrue($permanentReservation->isPermanent());
|
|
|
|
|
$this->assertFalse($permanentReservation->isTemporary());
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertTrue($temporaryReservation->isTemporary());
|
|
|
|
|
$this->assertFalse($temporaryReservation->isPermanent());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_belongs_to_a_product()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(20)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(5);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertInstanceOf(Product::class, $reservation->product);
|
|
|
|
|
$this->assertEquals($product->id, $reservation->product->id);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function product_has_many_stock_records()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(30)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$product->increaseStock(10);
|
|
|
|
|
$product->increaseStock(10);
|
|
|
|
|
$product->increaseStock(50);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertCount(4, $product->stocks);
|
|
|
|
|
$this->assertInstanceOf(ProductStock::class, $product->stocks->first());
|
|
|
|
|
$this->assertEquals(30 + 10 + 10 + 50, $product->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_get_active_stock_reservations()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(100)->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$activeReservation = $product->reserveStock(
|
|
|
|
|
quantity: 10,
|
|
|
|
|
until: now()->addHours(2)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
$expiredReservation = $product->reserveStock(
|
|
|
|
|
quantity: 5,
|
|
|
|
|
until: now()->subHours(1)
|
|
|
|
|
);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$activeReservations = $product->reservations()->get();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertCount(1, $activeReservations);
|
|
|
|
|
$this->assertEquals($activeReservation->id, $activeReservations->first()->id);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_cannot_release_stock_twice()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks()->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(5);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$this->assertTrue($reservation->release());
|
|
|
|
|
$this->assertFalse($reservation->release());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_can_store_reservation_note()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks()->create();
|
|
|
|
|
|
|
|
|
|
$note = "Customer requested to hold this item for 2 days.";
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation = $product->reserveStock(
|
2025-11-21 10:49:41 +00:00
|
|
|
quantity: 5,
|
2025-11-25 11:33:42 +00:00
|
|
|
note: $note
|
2025-11-21 10:49:41 +00:00
|
|
|
);
|
|
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals($note, $reservation->note);
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_calculates_available_stock_correctly()
|
|
|
|
|
{
|
2025-11-25 11:33:42 +00:00
|
|
|
$product = Product::factory()->withStocks(100)->create();
|
|
|
|
|
|
|
|
|
|
$reservation1 = $product->reserveStock(
|
|
|
|
|
quantity: 10,
|
|
|
|
|
until: now()->addHours(2)
|
|
|
|
|
);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation2 = $product->reserveStock(
|
|
|
|
|
quantity: 5,
|
|
|
|
|
until: now()->addHours(1)
|
|
|
|
|
);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$reservation1->refresh();
|
|
|
|
|
$reservation2->refresh();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 11:33:42 +00:00
|
|
|
$this->assertEquals(85, $product->refresh()->getAvailableStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function product_tracks_low_stock_threshold()
|
|
|
|
|
{
|
2025-11-25 16:02:39 +00:00
|
|
|
$product = Product::factory()
|
|
|
|
|
->withStocks(12)
|
|
|
|
|
->create([
|
|
|
|
|
'low_stock_threshold' => 10,
|
|
|
|
|
]);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
|
|
|
|
$this->assertFalse($product->isLowStock());
|
|
|
|
|
|
|
|
|
|
$product->decreaseStock(8);
|
|
|
|
|
|
|
|
|
|
$this->assertTrue($product->fresh()->isLowStock());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** @test */
|
|
|
|
|
public function it_updates_in_stock_status_automatically()
|
|
|
|
|
{
|
2025-11-25 16:02:39 +00:00
|
|
|
$product = Product::factory()
|
|
|
|
|
->withStocks(10)
|
|
|
|
|
->create();
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 16:02:39 +00:00
|
|
|
$product->decreaseStock(10);
|
2025-11-21 10:49:41 +00:00
|
|
|
|
2025-11-25 16:02:39 +00:00
|
|
|
$this->assertFalse($product->fresh()->isInStock());
|
2025-11-21 10:49:41 +00:00
|
|
|
}
|
|
|
|
|
}
|