laravel-shop/tests/Feature/Loan/LoanStockEventsTest.php

167 lines
5.8 KiB
PHP

<?php
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Events\StockDecreased;
use Blax\Shop\Events\StockDepleted;
use Blax\Shop\Events\StockIncreased;
use Blax\Shop\Events\StockReplenished;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Loans go through HasStocks::decreaseStock() and (host-driven) increaseStock()
* for return, so they automatically participate in the StockDecreased /
* StockIncreased / StockDepleted / StockReplenished event chain. EventsWiredUpTest
* proves those events fire for direct decrease/increase calls; this file
* pins down that the LOAN-driven paths (checkOutTo, markReturned-then-restock)
* benefit from the same wiring, so external listeners (low-stock alerts,
* search reindex, librarian notifications) react identically regardless of
* whether stock moved via a checkout or a loan.
*/
class LoanStockEventsTest extends TestCase
{
use RefreshDatabase;
private User $borrower;
private EventLoanBook $book;
protected function setUp(): void
{
parent::setUp();
$this->borrower = User::factory()->create();
$this->book = EventLoanBook::create(['name' => 'Hyperion', 'sku' => 'HYP-EV-1']);
$this->book->increaseStock(3);
}
#[Test]
public function checkOutTo_dispatches_stock_decreased_with_correct_payload(): void
{
Event::fake([StockDecreased::class]);
$this->book->checkOutTo($this->borrower);
Event::assertDispatched(
StockDecreased::class,
fn (StockDecreased $e) => $e->product->is($this->book)
&& $e->availableAfter === 2
&& $e->entry instanceof ProductStock
&& (int) $e->entry->quantity === -1
&& $e->entry->type === StockType::DECREASE
&& $e->entry->status === StockStatus::COMPLETED,
);
}
#[Test]
public function checking_out_the_last_copy_dispatches_stock_depleted(): void
{
// 3 copies on the shelf — borrow all three; the third call crosses the
// last-copy boundary so StockDepleted must fire alongside StockDecreased.
$this->book->checkOutTo(User::factory()->create());
$this->book->checkOutTo(User::factory()->create());
Event::fake([StockDepleted::class, StockDecreased::class]);
$this->book->checkOutTo($this->borrower);
Event::assertDispatched(StockDecreased::class);
Event::assertDispatched(
StockDepleted::class,
fn (StockDepleted $e) => $e->product->is($this->book),
);
}
#[Test]
public function partial_checkout_does_not_dispatch_stock_depleted(): void
{
Event::fake([StockDepleted::class]);
$this->book->checkOutTo($this->borrower);
Event::assertNotDispatched(StockDepleted::class);
}
#[Test]
public function restocking_after_a_full_loan_dispatches_stock_replenished(): void
{
// Single-copy book, borrow it (depletes to 0), then a host-driven
// increaseStock(1) on the return path must cross 0→>0 and fire
// StockReplenished. Mirrors what moonshiner-library does in
// LoanController::returnLoan after $loan->markReturned().
$single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']);
$single->increaseStock(1);
$loan = $single->checkOutTo($this->borrower);
$loan->markReturned();
$this->assertSame(0, $single->fresh()->getAvailableStock());
Event::fake([StockReplenished::class, StockIncreased::class]);
$single->increaseStock(1);
Event::assertDispatched(StockIncreased::class);
Event::assertDispatched(
StockReplenished::class,
fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1,
);
}
#[Test]
public function restocking_when_other_copies_are_free_does_not_dispatch_replenished(): void
{
// 3-copy book, borrow 1 → 2 available. Returning that copy goes 2→3,
// NOT a 0→>0 transition, so StockReplenished must stay silent.
$loan = $this->book->checkOutTo($this->borrower);
$loan->markReturned();
Event::fake([StockReplenished::class]);
$this->book->increaseStock(1);
Event::assertNotDispatched(StockReplenished::class);
}
#[Test]
public function event_wiring_holds_across_a_full_borrow_return_cycle(): void
{
// Full sequence: borrow → return-restock. We assert the relative count
// and payload of each event in one go so a future refactor that splits
// the path can't pass the per-step tests while breaking the rollup.
Event::fake([
StockDecreased::class,
StockIncreased::class,
StockDepleted::class,
StockReplenished::class,
]);
$loan = $this->book->checkOutTo($this->borrower);
$loan->markReturned();
$this->book->increaseStock(1);
Event::assertDispatchedTimes(StockDecreased::class, 1);
Event::assertDispatchedTimes(StockIncreased::class, 1);
Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion');
Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment');
}
}
/**
* Same plug-n-pray fixture as CheckOutToTest's: declare DEFAULT_TYPE so the
* MayBeLoanableProduct creating-hook flips the row into loan mode.
*/
class EventLoanBook extends Product
{
public const DEFAULT_TYPE = ProductType::LOANABLE;
protected $guarded = [];
}