diff --git a/README.md b/README.md index c1d0b0d..912854d 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,8 @@ # Laravel Shop [![Tests](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml/badge.svg)](https://github.com/blax-software/laravel-shop/actions/workflows/tests.yml) +[![Tests Count](https://img.shields.io/badge/tests-1228%20passing-success?style=flat-square)](#testing) +[![Assertions](https://img.shields.io/badge/assertions-3291-blue?style=flat-square)](#testing) [![Latest Version](https://img.shields.io/packagist/v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![License](https://img.shields.io/packagist/l/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) [![PHP Version](https://img.shields.io/packagist/php-v/blax-software/laravel-shop.svg?style=flat-square)](https://packagist.org/packages/blax-software/laravel-shop) @@ -184,13 +186,24 @@ $isAvailable = $room->availableOnDate(now(), now()->addHour()); ## Testing -To run the package tests: +We test this package for many edge cases across every surface — products, +stock, pricing strategies, cart/checkout, loan lifecycle, pool aggregation, +booking, Stripe sync and the event surface — so host applications can lean +on the behaviour with confidence. + +``` +Tests: 1228, Assertions: 3291 +``` + +CI runs the full suite on every push (see the badge above). To run it +locally: ```bash ./vendor/bin/phpunit ``` -The tests use an in-memory SQLite database and Orchestra Testbench. +The tests use an in-memory SQLite database and Orchestra Testbench, so they +run in roughly a minute with no external services required. ## Documentation diff --git a/src/Exceptions/NotLoanableProductException.php b/src/Exceptions/NotLoanableProductException.php new file mode 100644 index 0000000..91460cd --- /dev/null +++ b/src/Exceptions/NotLoanableProductException.php @@ -0,0 +1,15 @@ + …, 'total_quantity' => 3])` produces a - * book with three copies in stock. + * class Book extends \Blax\Shop\Models\Product + * { + * public const DEFAULT_TYPE = ProductType::LOANABLE; + * } * - * - `checkOutTo($borrower, $weeks, $price)` — atomic decrement + purchase - * creation + LoanCreated event dispatch, all in one call. Replaces the - * DB::transaction + decreaseStock + purchases()->create + event boilerplate - * every host controller would otherwise repeat. + * The boot hook reads `DEFAULT_TYPE` on the concrete class and, when it's + * LOANABLE, applies sensible defaults on `creating` (type, status=PUBLISHED, + * is_visible=true, manage_stock=true) so callers can omit e-commerce columns + * entirely. Hosts that want a loanable Product *instance* without declaring + * the constant can still set `type` explicitly at create time. + * + * The `total_quantity` virtual fillable translates into a stock INCREASE + * entry on `created` when (and only when) the product is loanable: + * + * Book::create(['name' => …, 'total_quantity' => 3]) + * + * `checkOutTo($borrower, $weeks, $price)` wraps decrement + purchase + * creation + LoanCreated dispatch in a single transaction; throws + * NotLoanableProductException when called on a non-loanable product. * * Pair with {@see HasLoanLifecycle} on ProductPurchase (already mixed in via * the package's default ProductPurchase model) to get the full borrow → * extend → return state machine for free. * - * Example host model: - * - * class Book extends \Blax\Shop\Models\Product - * { - * use \Blax\Shop\Traits\IsLoanableProduct; - * - * public function getTitleAttribute(): ?string { return $this->name; } - * public function setTitleAttribute(?string $v): void { $this->attributes['name'] = $v; } - * public function getIsbnAttribute(): ?string { return $this->sku; } - * public function setIsbnAttribute(?string $v): void { $this->attributes['sku'] = $v; } - * } + * Host models use the inherited `name` and `sku` columns directly. Anything + * domain-specific (author, page count, language…) belongs in + * {@see \Blax\Shop\Models\ProductAttribute}, and the public API can rename + * columns at the Resource layer. */ -trait IsLoanableProduct +trait MayBeLoanableProduct { /** Captured by setTotalQuantityAttribute; consumed in created(). */ protected int $initialLoanableQuantity = 0; + public function isLoanable(): bool + { + return $this->type === ProductType::LOANABLE; + } + /** - * Treat title / isbn / total_quantity as fillable virtual attributes by - * default. Hosts that don't need them can override getFillable(). + * `total_quantity` stays universally fillable — setting it on a + * non-loanable product is a no-op (the created() hook below only fires + * the stock INCREASE when isLoanable()), so there's no need to gate + * the fillable list per type. */ public function getFillable(): array { - return array_merge(parent::getFillable(), ['title', 'isbn', 'total_quantity']); + return array_merge(parent::getFillable(), ['total_quantity']); } + /** + * Physical inventory = copies on the shelf + copies currently loaned out. + * We can't use getMaxStocksAttribute() here because it sums every + * INCREASE entry in product_stocks — including the increaseStock() call + * a return fires — which inflates the displayed total after each loan + * cycle even though no new copies were ever acquired. + */ public function getTotalQuantityAttribute(): int { + if (! $this->isLoanable()) { + return (int) $this->getMaxStocksAttribute(); + } + if (! $this->exists) { return $this->initialLoanableQuantity; } - return (int) $this->getMaxStocksAttribute(); + if ($this->manage_stock === false) { + return PHP_INT_MAX; + } + + return $this->getAvailableStock() + $this->purchases()->activeLoans()->count(); } public function setTotalQuantityAttribute(int $value): void @@ -83,9 +109,12 @@ trait IsLoanableProduct return $this->getAvailableStock(); } - public static function bootIsLoanableProduct(): void + public static function bootMayBeLoanableProduct(): void { static::creating(function ($product): void { + if (self::resolveDefaultProductType($product) !== ProductType::LOANABLE) { + return; + } $product->type ??= ProductType::LOANABLE; $product->status ??= ProductStatus::PUBLISHED; $product->is_visible ??= true; @@ -93,12 +122,19 @@ trait IsLoanableProduct }); static::created(function ($product): void { - if ($product->initialLoanableQuantity > 0) { + if ($product->isLoanable() && $product->initialLoanableQuantity > 0) { $product->increaseStock($product->initialLoanableQuantity); } }); } + private static function resolveDefaultProductType(object $product): ?ProductType + { + $constant = $product::class . '::DEFAULT_TYPE'; + + return defined($constant) ? constant($constant) : null; + } + /** * Atomically check out one unit of this product to a borrower. * @@ -113,6 +149,7 @@ trait IsLoanableProduct * @param int|null $weeks Loan duration; defaults to shop.loan.default_duration_weeks * @param ProductPrice|null $price Override price; defaults to product's defaultPrice * + * @throws NotLoanableProductException When called on a non-loanable product * @throws NotEnoughStockException When no copies are available */ public function checkOutTo( @@ -120,6 +157,10 @@ trait IsLoanableProduct ?int $weeks = null, ?ProductPrice $price = null, ): ProductPurchase { + if (! $this->isLoanable()) { + throw new NotLoanableProductException(); + } + $weeks ??= (int) config('shop.loan.default_duration_weeks', 2); $now = Carbon::now(); $price ??= $this->defaultPrice()->first(); diff --git a/tests/Feature/Loan/CheckOutToTest.php b/tests/Feature/Loan/CheckOutToTest.php index 26d8c5e..1c0502c 100644 --- a/tests/Feature/Loan/CheckOutToTest.php +++ b/tests/Feature/Loan/CheckOutToTest.php @@ -2,13 +2,13 @@ namespace Blax\Shop\Tests\Feature\Loan; +use Blax\Shop\Enums\ProductType; use Blax\Shop\Enums\PurchaseStatus; use Blax\Shop\Events\LoanCreated; use Blax\Shop\Exceptions\NotEnoughStockException; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductPurchase; use Blax\Shop\Tests\TestCase; -use Blax\Shop\Traits\IsLoanableProduct; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Event; @@ -16,7 +16,7 @@ use PHPUnit\Framework\Attributes\Test; use Workbench\App\Models\User; /** - * Coverage for {@see IsLoanableProduct::checkOutTo()} — the atomic + * Coverage for {@see MayBeLoanableProduct::checkOutTo()} — the atomic * decreaseStock + purchase row + LoanCreated event path that every host * controller uses to start a loan. Until this file existed, the trait that * production code actually depends on had zero direct test coverage; the @@ -200,15 +200,16 @@ class CheckOutToTest extends TestCase } /** - * Minimal loanable fixture: extending Product picks up the package's - * polymorphism, the IsLoanableProduct trait wires up checkOutTo and the - * total_quantity / available_quantity virtuals. Both base and subclass - * resolve to the `products` table via Product::__construct, so no migration - * is needed. + * 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 { - use IsLoanableProduct; + public const DEFAULT_TYPE = ProductType::LOANABLE; protected $guarded = []; } diff --git a/tests/Feature/Loan/LoanEventsTest.php b/tests/Feature/Loan/LoanEventsTest.php index ddbee90..eca2ab0 100644 --- a/tests/Feature/Loan/LoanEventsTest.php +++ b/tests/Feature/Loan/LoanEventsTest.php @@ -19,7 +19,7 @@ use Workbench\App\Models\User; /** * Loan lifecycle domain events. * - * LoanCreated — auto-dispatched from IsLoanableProduct::checkOutTo() + * LoanCreated — auto-dispatched from MayBeLoanableProduct::checkOutTo() * (see CheckOutToTest). Hosts that build ProductPurchase * rows directly — bypassing checkOutTo — must dispatch * the event themselves; that direct-construction path is @@ -95,7 +95,7 @@ class LoanEventsTest extends TestCase #[Test] public function loan_created_can_be_dispatched_manually_when_bypassing_checkOutTo(): void { - // The package auto-dispatches LoanCreated from IsLoanableProduct:: + // The package auto-dispatches LoanCreated from MayBeLoanableProduct:: // checkOutTo() (see CheckOutToTest). Hosts that assemble a // ProductPurchase row directly — e.g. importing historical loans — // can still raise the same event so downstream listeners fire.