'Heirloom Tomato', 'sku' => 'TOM-PHYS', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'is_visible' => true, 'manage_stock' => true, ]); $tomato->increaseStock($initialStock); return $tomato; } private function book(int $copies): Product { $book = Product::create([ 'name' => 'Hyperion', 'sku' => 'HYP-PHYS', 'type' => ProductType::LOANABLE, 'status' => ProductStatus::PUBLISHED, 'is_visible' => true, 'manage_stock' => true, ]); $book->increaseStock($copies); return $book; } private function room(): Product { $room = Product::create([ 'name' => 'Suite 1', 'sku' => 'ROOM-PHYS', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'is_visible' => true, 'manage_stock' => true, ]); $room->increaseStock(1); return $room; } /* ──────────────── consumable (tomato shop) ──────────────── */ #[Test] public function tomato_shop_physical_equals_available_with_no_activity(): void { $tomato = $this->tomato(10); $this->assertSame(10, $tomato->getPhysicalStock()); $this->assertSame(10, $tomato->physical_stock); $this->assertSame(10, $tomato->getAvailableStock()); } #[Test] public function selling_a_tomato_reduces_physical_and_available_in_lockstep(): void { // A sale is recorded as a permanent DECREASE — no offsetting active // loan or claim. Both physical and available drop by the sold count. $tomato = $this->tomato(10); $tomato->decreaseStock(3); $this->assertSame(7, $tomato->getAvailableStock(), '3 sold → 7 on the shelf'); $this->assertSame(7, $tomato->getPhysicalStock(), 'sold tomatoes are eaten — gone from physical inventory too'); } /* ──────────────── loanable (library book) ──────────────── */ #[Test] public function loaning_a_book_drops_available_but_not_physical(): void { // Loaned books still belong to the library — physical stays at the // catalogue size, available drops by the loaned count. $book = $this->book(5); $borrower = User::factory()->create(); $book->checkOutTo($borrower); $fresh = $book->fresh(); $this->assertSame(4, $fresh->getAvailableStock(), 'one copy is out → 4 on the shelf'); $this->assertSame(5, $fresh->getPhysicalStock(), 'the loaned copy is still ours → physical = 5'); } #[Test] public function returning_a_loan_restores_available_and_physical_stays_steady(): void { $book = $this->book(5); $borrower = User::factory()->create(); $loan = $book->checkOutTo($borrower); $this->assertSame(5, $book->fresh()->getPhysicalStock()); // markReturned() now does the full job — releases the paired // physical claim, which writes the offsetting RETURN entry. No // host-side increaseStock(1) needed. $loan->markReturned(); $fresh = $book->fresh(); $this->assertSame(5, $fresh->getAvailableStock()); $this->assertSame(5, $fresh->getPhysicalStock(), 'physical never wavered through the loan cycle'); } #[Test] public function multiple_concurrent_loans_each_contribute_to_physical(): void { $book = $this->book(5); $book->checkOutTo(User::factory()->create()); $book->checkOutTo(User::factory()->create()); $book->checkOutTo(User::factory()->create()); $fresh = $book->fresh(); $this->assertSame(2, $fresh->getAvailableStock(), '3 out → 2 on shelf'); $this->assertSame(5, $fresh->getPhysicalStock(), '3 active loans + 2 available = 5 owned'); } /* ──────────────── booking (hotel room) ──────────────── */ #[Test] public function active_claim_counts_toward_physical(): void { // A claim active right now (e.g. a cart reservation) holds the unit // back from new use, but the business still owns it. Carbon::setTestNow(Carbon::parse('2026-05-17 12:00:00')); $room = $this->room(); $room->claimStock( 1, null, Carbon::parse('2026-05-17 09:00:00'), Carbon::parse('2026-05-17 18:00:00'), 'cart hold', ); $fresh = $room->fresh(); $this->assertSame(0, $fresh->getAvailableStock()); $this->assertSame(1, $fresh->getCurrentlyClaimedStock()); $this->assertSame(1, $fresh->getPhysicalStock(), 'claimed-but-still-ours counts as physical'); } #[Test] public function future_only_claim_does_not_inflate_physical(): void { // A booking starting tomorrow doesn't reduce today's available stock, // so it must not double-count toward today's physical either. Carbon::setTestNow(Carbon::parse('2026-05-17 12:00:00')); $room = $this->room(); $room->claimStock( 1, null, Carbon::parse('2026-05-20 09:00:00'), Carbon::parse('2026-05-21 09:00:00'), 'next-week booking', ); $fresh = $room->fresh(); $this->assertSame(1, $fresh->getAvailableStock(), 'future booking does not reduce today'); $this->assertSame(0, $fresh->getCurrentlyClaimedStock(), 'future claim is not "current"'); $this->assertSame(1, $fresh->getPhysicalStock(), 'physical = 1 + 0 + 0 = 1'); } /* ──────────────── unmanaged stock ──────────────── */ #[Test] public function unmanaged_stock_reports_infinite_physical(): void { // manage_stock=false makes available/physical both PHP_INT_MAX — the // package's universal "no scarcity" signal. $product = Product::create([ 'name' => 'eBook', 'sku' => 'EB-PHYS', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'is_visible' => true, 'manage_stock' => false, ]); $this->assertSame(PHP_INT_MAX, $product->getPhysicalStock()); } /* ──────────────── distinct from existing accessors ──────────────── */ #[Test] public function physical_does_not_inflate_after_a_library_loan_return_cycle(): void { // Regression: getMaxStocksAttribute sums every INCREASE row — the // claim/release machinery writes a RETURN row at return time, which // is INCREASE-like and so still inflates "Assigned". physical_stock // uses the available+claims formula instead and stays correctly at 5 // through any number of cycles. $book = $this->book(5); $loan = $book->checkOutTo(User::factory()->create()); $loan->markReturned(); $fresh = $book->fresh(); $this->assertGreaterThan(5, $fresh->getMaxStocksAttribute(), 'documented limitation: max inflates per cycle'); $this->assertSame(5, $fresh->getPhysicalStock(), 'physical stays at the real owned count'); } #[Test] public function multi_unit_physical_claim_aggregates_into_physical(): void { // Defensive coverage for the claim-based loan model: a // PHYSICALLY_CLAIMED row with quantity > 1 should contribute its // full quantity to physical (10 = 7 on shelf + 3 claimed). $book = $this->book(10); $book->claimStock( quantity: 3, from: now(), until: now()->addWeeks(2), type: \Blax\Shop\Enums\StockType::PHYSICALLY_CLAIMED, ); $fresh = $book->fresh(); $this->assertSame(7, $fresh->getAvailableStock()); $this->assertSame(3, $fresh->getCurrentlyClaimedStock()); $this->assertSame(10, $fresh->getPhysicalStock(), '7 on shelf + 3 physically claimed = 10 owned'); } }