borrower = User::factory()->create(); $this->book = LoanableBook::create([ 'name' => 'Hyperion', 'sku' => '9780553283686', ]); $this->book->increaseStock(3); } #[Test] public function it_creates_a_pending_purchase_decrements_stock_and_dispatches_loan_created(): void { Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); Event::fake([LoanCreated::class]); $loan = $this->book->checkOutTo($this->borrower); $this->assertInstanceOf(ProductPurchase::class, $loan); $this->assertTrue($loan->exists); $this->assertSame(PurchaseStatus::PENDING, $loan->status); $this->assertSame(1, $loan->quantity); $this->assertSame(0, (int) $loan->amount); $this->assertSame(0, (int) $loan->amount_paid); $this->assertSame( Carbon::parse('2026-05-14 10:00:00')->toDateTimeString(), $loan->from->toDateTimeString(), ); $this->assertSame( Carbon::parse('2026-05-28 10:00:00')->toDateTimeString(), $loan->until->toDateTimeString(), ); $this->assertSame(0, (int) ((array) $loan->meta)['extensions_used'] ?? 99); $this->assertSame(2, $this->book->fresh()->getAvailableStock()); Event::assertDispatched( LoanCreated::class, fn (LoanCreated $event) => $event->loan->is($loan), ); } #[Test] public function it_honours_the_explicit_weeks_argument(): void { Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); $loan = $this->book->checkOutTo($this->borrower, weeks: 4); $this->assertSame( Carbon::parse('2026-06-11 10:00:00')->toDateTimeString(), $loan->until->toDateTimeString(), ); } #[Test] public function it_falls_back_to_the_shop_loan_default_duration_weeks_config(): void { config(['shop.loan.default_duration_weeks' => 3]); Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); $loan = $this->book->checkOutTo($this->borrower); $this->assertSame( Carbon::parse('2026-06-04 10:00:00')->toDateTimeString(), $loan->until->toDateTimeString(), ); } #[Test] public function it_throws_not_enough_stock_when_no_copies_are_available(): void { $only = LoanableBook::create(['name' => 'Solitaire', 'sku' => 'S-1']); $only->increaseStock(1); $only->checkOutTo($this->borrower); $this->expectException(NotEnoughStockException::class); $only->checkOutTo(User::factory()->create()); } #[Test] public function it_is_atomic_no_purchase_row_remains_when_stock_decrement_fails(): void { // Stock is 0; decreaseStock throws inside the transaction. The // wrapping DB::transaction must roll back, leaving no purchase row. $empty = LoanableBook::create(['name' => 'Out of Print', 'sku' => 'OOP-1']); $baseline = ProductPurchase::query() ->where('purchasable_id', $empty->id) ->count(); try { $empty->checkOutTo($this->borrower); $this->fail('checkOutTo should have thrown NotEnoughStockException.'); } catch (NotEnoughStockException) { // expected } $this->assertSame( $baseline, ProductPurchase::query()->where('purchasable_id', $empty->id)->count(), 'A failed checkOutTo must not leave a dangling purchase row.', ); } #[Test] public function contention_on_a_single_copy_is_resolved_first_caller_wins(): void { // Two borrowers race for the only copy. The first call succeeds; the // second must fail with NotEnoughStockException — the controller's // job is then to surface that as a friendly validation error. $single = LoanableBook::create(['name' => 'Singular', 'sku' => 'SNG-1']); $single->increaseStock(1); $alice = User::factory()->create(); $bob = User::factory()->create(); $single->checkOutTo($alice); $this->expectException(NotEnoughStockException::class); $single->checkOutTo($bob); } #[Test] public function manage_stock_false_serves_unlimited_concurrent_borrowers(): void { // manage_stock=false ⇒ getAvailableStock returns PHP_INT_MAX and // decreaseStock short-circuits, so checkOutTo never blocks. $infinite = LoanableBook::create([ 'name' => 'The Infinite Compendium', 'sku' => 'INF-1', 'manage_stock' => false, ]); $borrowers = User::factory()->count(5)->create(); foreach ($borrowers as $borrower) { $infinite->checkOutTo($borrower); } $this->assertSame( 5, ProductPurchase::query()->where('purchasable_id', $infinite->id)->count(), ); } #[Test] public function mark_returned_releases_the_paired_physical_claim_and_restores_stock(): void { // Replaces the prior "must not restore stock" assertion. Loans are // now modelled as PHYSICALLY_CLAIMED stock entries; markReturned() // releases that claim, which creates the offsetting RETURN row via // ProductStock::release() — so available stock comes back to where // it was before the loan with no host bookkeeping required. $availableBefore = $this->book->fresh()->getAvailableStock(); $loan = $this->book->checkOutTo($this->borrower); $this->assertSame( $availableBefore - 1, $this->book->fresh()->getAvailableStock(), 'checkout drops available by quantity', ); $loan->markReturned(); $this->assertSame( $availableBefore, $this->book->fresh()->getAvailableStock(), 'markReturned() restores available via the released claim', ); } #[Test] public function mark_returned_is_idempotent_and_does_not_double_restore_stock(): void { // A retried markReturned() call (network flake, double-click on a // librarian button, etc.) must not inflate stock past the catalogue // size by re-releasing an already-released claim. $availableBefore = $this->book->fresh()->getAvailableStock(); $loan = $this->book->checkOutTo($this->borrower); $loan->markReturned(); $afterFirst = $this->book->fresh()->getAvailableStock(); $loan->markReturned(); $afterSecond = $this->book->fresh()->getAvailableStock(); $this->assertSame($availableBefore, $afterFirst); $this->assertSame($afterFirst, $afterSecond, 'second call must be a no-op'); } } /** * Minimal loanable fixture: extending Product gives us the package's * polymorphism and the MayBeLoanableProduct helpers (it's mixed into Product * itself); declaring DEFAULT_TYPE = LOANABLE is the plug-n-pray opt-in that * flips checkOutTo and the total_quantity / available_quantity virtuals into * loan mode. Both base and subclass resolve to the `products` table via * Product::__construct, so no migration is needed. */ class LoanableBook extends Product { public const DEFAULT_TYPE = ProductType::LOANABLE; protected $guarded = []; }