BF edgecases, R structure, A tests

This commit is contained in:
Fabian @ Blax Software 2026-05-18 13:05:38 +02:00
parent ab8ea6afec
commit 0cfbdf221d
17 changed files with 674 additions and 63 deletions

View File

@ -174,4 +174,21 @@ return [
'max_extensions' => env('SHOP_LOAN_MAX_EXTENSIONS', 2), 'max_extensions' => env('SHOP_LOAN_MAX_EXTENSIONS', 2),
], ],
/*
|--------------------------------------------------------------------------
| API pagination
|--------------------------------------------------------------------------
|
| Consumed by the public API controllers (Http/Controllers/Api/*).
| `per_page` default page size when the request doesn't specify one.
| `max_per_page` upper bound the controller will honour regardless of
| what the client asks for, so a malicious or careless
| caller can't request all rows in one request.
|
*/
'pagination' => [
'per_page' => env('SHOP_API_PER_PAGE', 24),
'max_per_page' => env('SHOP_API_MAX_PER_PAGE', 100),
],
]; ];

View File

@ -48,9 +48,8 @@ class ProductFactory extends Factory
'is_visible' => true, 'is_visible' => true,
'featured' => false, 'featured' => false,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => $this->faker->numberBetween(0, 100), // Stock counts live in the ProductStock ledger — use the
'in_stock' => true, // ->withStocks(int) state to seed an initial INCREASE entry.
'stock_status' => 'instock',
'published_at' => now(), 'published_at' => now(),
'meta' => json_encode(new \stdClass()), 'meta' => json_encode(new \stdClass()),
]; ];
@ -58,11 +57,10 @@ class ProductFactory extends Factory
public function outOfStock(): static public function outOfStock(): static
{ {
return $this->state([ // manage_stock=true + no ledger entries → isInStock() returns false,
'stock_quantity' => 0, // getAvailableStock() returns 0. That IS the out-of-stock state under
'in_stock' => false, // the ledger-only model — no extra columns required.
'stock_status' => 'outofstock', return $this->state(['manage_stock' => true]);
]);
} }
public function variable(): static public function variable(): static

View File

@ -25,10 +25,13 @@ return new class extends Migration
$table->timestamp('sale_start')->nullable(); $table->timestamp('sale_start')->nullable();
$table->timestamp('sale_end')->nullable(); $table->timestamp('sale_end')->nullable();
$table->boolean('manage_stock')->default(false); $table->boolean('manage_stock')->default(false);
$table->integer('stock_quantity')->default(0); // Live stock counts live in the ProductStock ledger — there
// is intentionally no denormalised column on products. See
// HasStocks::getAvailableStock() for the canonical read.
$table->integer('low_stock_threshold')->nullable(); $table->integer('low_stock_threshold')->nullable();
$table->boolean('in_stock')->default(true); // Live stock state (in-stock?, status) is computed from the
$table->string('stock_status')->default('instock'); // instock, outofstock, onbackorder // ProductStock ledger — see HasStocks::isInStock / scopeInStock.
// No denormalised columns on products.
$table->decimal('weight', 10, 2)->nullable(); $table->decimal('weight', 10, 2)->nullable();
$table->decimal('length', 10, 2)->nullable(); $table->decimal('length', 10, 2)->nullable();
$table->decimal('width', 10, 2)->nullable(); $table->decimal('width', 10, 2)->nullable();
@ -65,7 +68,11 @@ return new class extends Migration
$table->longText('description')->nullable()->after('short_description'); $table->longText('description')->nullable()->after('short_description');
} }
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'low_stock_threshold')) { if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'low_stock_threshold')) {
$table->integer('low_stock_threshold')->nullable()->after('stock_quantity'); // `manage_stock` is the stable anchor here — the legacy
// `stock_quantity` column is being dropped in a later
// migration, so anchoring against it would fail once that
// runs.
$table->integer('low_stock_threshold')->nullable()->after('manage_stock');
} }
if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'published_at')) { if (!Schema::hasColumn(config('shop.tables.products', 'products'), 'published_at')) {
$table->timestamp('published_at')->nullable()->after('status'); $table->timestamp('published_at')->nullable()->after('status');

View File

@ -0,0 +1,54 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the redundant `stock_quantity` column from `products`.
*
* Stock is the responsibility of the `ProductStock` ledger table; the
* `stock_quantity` column on `products` is a stale denormalisation that
* caused frontends to mis-read availability (e.g. treating "no ledger
* entries yet" as "out of stock" when looking at this column). Drop it.
*
* If you want to seed initial stock during product creation, call
* `$product->increaseStock($qty)` after `Product::create([...])` that
* writes a single INCREASE entry into `ProductStock`, the same way every
* other stock change flows through the system.
*
* The down() restores the column for rollback only; it does NOT backfill
* historical values. Up before rolling back: aggregate the ledger into a
* temporary value if you actually need a number per product.
*/
return new class extends Migration {
public function up(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
if (Schema::hasColumn($table, 'stock_quantity')) {
Schema::table($table, function (Blueprint $t) {
$t->dropColumn('stock_quantity');
});
}
}
public function down(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
if (!Schema::hasColumn($table, 'stock_quantity')) {
Schema::table($table, function (Blueprint $t) {
$t->integer('stock_quantity')->default(0)->after('manage_stock');
});
}
}
};

View File

@ -0,0 +1,66 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Drop the redundant `in_stock` and `stock_status` columns from `products`.
*
* Both fields were stale denormalisations of "does the product have stock?".
* Every consumer in the package already routes through the ProductStock
* ledger:
*
* - `HasStocks::isInStock()` checks `manage_stock` + getAvailableStock()
* - `HasStocks::scopeInStock()` SUMs the ledger directly
*
* No code reads either column. Dropping them removes a foot-gun where the
* column could disagree with the live ledger state (e.g. an order was placed,
* stock dropped to 0, but nobody updated `in_stock` frontend shows the
* product as orderable when it isn't).
*
* Down restores the columns with their original defaults so a rollback is
* lossless from a schema perspective (the data itself is not backfilled
* if you need it, derive it post-hoc from the ledger).
*/
return new class extends Migration {
public function up(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
$cols = [];
if (Schema::hasColumn($table, 'in_stock')) {
$cols[] = 'in_stock';
}
if (Schema::hasColumn($table, 'stock_status')) {
$cols[] = 'stock_status';
}
if (!empty($cols)) {
$t->dropColumn($cols);
}
});
}
public function down(): void
{
$table = config('shop.tables.products', 'products');
if (!Schema::hasTable($table)) {
return;
}
Schema::table($table, function (Blueprint $t) use ($table) {
if (!Schema::hasColumn($table, 'in_stock')) {
$t->boolean('in_stock')->default(true)->after('manage_stock');
}
if (!Schema::hasColumn($table, 'stock_status')) {
$t->string('stock_status')->default('instock')->after('in_stock');
}
});
}
};

View File

@ -228,8 +228,10 @@ class ShopAddExampleProducts extends Command
} elseif ($type === ProductType::POOL->value) { } elseif ($type === ProductType::POOL->value) {
$this->addPoolItemsForHotel($product, $productData); $this->addPoolItemsForHotel($product, $productData);
} elseif ($type === ProductType::BOOKING->value) { } elseif ($type === ProductType::BOOKING->value) {
// Bookings need stock to be bookable // Bookings need stock to be bookable. Read the canonical live
if ($product->stock_quantity === 0) { // count from the ledger (getAvailableStock) — no `stock_quantity`
// column to consult anymore.
if ($product->getAvailableStock() === 0) {
$product->increaseStock($productData['stock'] ?? 10); $product->increaseStock($productData['stock'] ?? 10);
} }
} }

View File

@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Blax\Shop\Exceptions;
use Exception;
class CartNotReadyException extends Exception
{
public function __construct(string $message = "Cart is not ready for checkout. Some items may be unavailable.")
{
parent::__construct($message);
}
}

View File

@ -13,10 +13,14 @@ class ProductController extends Controller
{ {
$productModel = config('shop.models.product'); $productModel = config('shop.models.product');
$perPage = min( // Honour the request, fall back to the configured default, clamp to
request('per_page', config('shop.pagination.per_page')), // the configured max. Defaults are applied here too so a missing
config('shop.pagination.max_per_page') // `shop.pagination.*` key can never collapse `min()` to 0 and
); // silently trigger Laravel's built-in default of 15.
$defaultPerPage = (int) (config('shop.pagination.per_page') ?? 24);
$maxPerPage = (int) (config('shop.pagination.max_per_page') ?? 100);
$perPage = (int) request('per_page', $defaultPerPage);
$perPage = max(1, min($perPage, $maxPerPage));
$query = $productModel::query() $query = $productModel::query()
->published() ->published()
@ -59,7 +63,6 @@ class ProductController extends Controller
[ [
'current_price' => $product->getCurrentPrice(), 'current_price' => $product->getCurrentPrice(),
'on_sale' => $product->isOnSale(), 'on_sale' => $product->isOnSale(),
'average_rating' => $product->getAverageRating(),
] ]
), ),
]); ]);

View File

@ -302,7 +302,10 @@ class ProductStock extends Model
DB::table('product_stock_logs')->insert([ DB::table('product_stock_logs')->insert([
'product_id' => $this->product_id, 'product_id' => $this->product_id,
'quantity_change' => -$this->quantity, 'quantity_change' => -$this->quantity,
'quantity_after' => $this->product->stock_quantity, // After this ledger row has been persisted, getAvailableStock()
// returns the canonical post-change count (sums the ProductStock
// ledger directly — no denormalised column needed).
'quantity_after' => $this->product?->getAvailableStock() ?? 0,
'type' => $this->type, 'type' => $this->type,
'note' => $this->note, 'note' => $this->note,
'reference_type' => $this->reference_type, 'reference_type' => $this->reference_type,

View File

@ -127,7 +127,12 @@ trait MayBePoolProduct
public function getPoolTotalCapacity(): int public function getPoolTotalCapacity(): int
{ {
if (!$this->isPool()) { if (!$this->isPool()) {
return $this->manage_stock ? ($this->stock_quantity ?? 0) : PHP_INT_MAX; // "Total capacity" = the ceiling of physical stock, not the live
// remaining count. `max_stocks` sums INCREASE + RETURN ledger
// entries, ignoring DECREASE and CLAIMED — exactly the semantic
// this method documents. Replaces the old `stock_quantity`
// denormalised column.
return $this->manage_stock ? (int) $this->max_stocks : PHP_INT_MAX;
} }
$singleItems = $this->singleProducts; $singleItems = $this->singleProducts;

View File

@ -32,10 +32,9 @@ class BookingFeatureTest extends TestCase
'slug' => 'hotel-room', 'slug' => 'hotel-room',
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
]); ]);
// Initialize stock // Seed stock via the ledger (the canonical source).
$this->bookingProduct->increaseStock(10); $this->bookingProduct->increaseStock(10);
// Create a price // Create a price

View File

@ -34,10 +34,9 @@ class BookingPerMinutePricingTest extends TestCase
'slug' => 'conference-room', 'slug' => 'conference-room',
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
]); ]);
// Initialize stock // Seed stock via the ledger (the canonical source).
$this->bookingProduct->increaseStock(10); $this->bookingProduct->increaseStock(10);
// Create a price: $100.00 per day (24 hours) // Create a price: $100.00 per day (24 hours)

View File

@ -470,10 +470,16 @@ class CartDateManagementTest extends TestCase
#[Test] #[Test]
public function validate_date_availability_marks_items_unavailable_when_product_not_available() public function validate_date_availability_marks_items_unavailable_when_product_not_available()
{ {
$product = Product::factory()->create([ // Single-unit booking product. Stock is real (one INCREASE entry in
// the ledger via withStocks) so addToCart can succeed pre-dates;
// the conflict that the validation must catch is simulated by an
// existing CLAIMED entry on the ledger — i.e. "a prior checkout
// already locked this unit for days 13". Claims are created at
// checkout time in real life, not by setting cart dates, so we
// place one directly here to exercise the date-overlap path.
$product = Product::factory()->withStocks(1)->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 1,
]); ]);
$price = ProductPrice::factory()->create([ $price = ProductPrice::factory()->create([
@ -481,19 +487,28 @@ class CartDateManagementTest extends TestCase
'purchasable_type' => Product::class, 'purchasable_type' => Product::class,
'type' => PriceType::RECURRING, 'type' => PriceType::RECURRING,
'is_default' => true, 'is_default' => true,
]); ]);
// Pre-existing claim that locks the unit for days 13.
$product->claimStock(
quantity: 1,
reference: null,
from: Carbon::now()->addDays(1),
until: Carbon::now()->addDays(3),
note: 'Test: existing booking blocks the single unit',
);
// Customer cart tries to book the same product for overlapping dates.
// addToCart succeeds (pool capacity = 1, no items yet); setDates
// must NOT throw, but must mark the booking item unavailable.
$cart = Cart::factory()->create(); $cart = Cart::factory()->create();
$item = $cart->addToCart($product, 1); $item = $cart->addToCart($product, 1);
$cart->setDates(
Carbon::now()->addDays(2),
Carbon::now()->addDays(4),
validateAvailability: true,
);
// Set item dates that consume the stock
$item->updateDates(Carbon::now()->addDays(1), Carbon::now()->addDays(3));
// Try to set cart dates that overlap - should NOT throw, instead mark items unavailable
$cart->setDates(Carbon::now()->addDays(2), Carbon::now()->addDays(4), validateAvailability: true);
// Item should now be marked as unavailable (null price)
$item->refresh(); $item->refresh();
$this->assertNull($item->price, 'Unavailable item should have null price'); $this->assertNull($item->price, 'Unavailable item should have null price');
$this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout'); $this->assertFalse($item->is_ready_to_checkout, 'Unavailable item should not be ready for checkout');
@ -502,10 +517,9 @@ class CartDateManagementTest extends TestCase
#[Test] #[Test]
public function apply_dates_to_items_marks_items_unavailable_when_product_not_available() public function apply_dates_to_items_marks_items_unavailable_when_product_not_available()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(1)->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 1,
]); ]);
$price = ProductPrice::factory()->create([ $price = ProductPrice::factory()->create([
@ -541,10 +555,12 @@ class CartDateManagementTest extends TestCase
#[Test] #[Test]
public function can_skip_validation_when_setting_dates() public function can_skip_validation_when_setting_dates()
{ {
// No `->withStocks(...)` — manage_stock=true with no ledger entries
// means getAvailableStock() returns 0. Same intent as the old
// 'stock_quantity' => 0.
$product = Product::factory()->create([ $product = Product::factory()->create([
'type' => ProductType::BOOKING, 'type' => ProductType::BOOKING,
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0, // No stock available
]); ]);
$price = ProductPrice::factory()->create([ $price = ProductPrice::factory()->create([

View File

@ -298,10 +298,9 @@ class PurchaseFlowTest extends TestCase
public function user_cannot_add_out_of_stock_product_to_cart() public function user_cannot_add_out_of_stock_product_to_cart()
{ {
$user = User::factory()->create(); $user = User::factory()->create();
// No ledger entries seeded — getAvailableStock() returns 0.
$product = Product::factory()->create([ $product = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
]); ]);
$this->expectException(\Exception::class); $this->expectException(\Exception::class);

View File

@ -0,0 +1,220 @@
<?php
namespace Blax\Shop\Tests\Feature\Http;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductCategory;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
/**
* Feature coverage for the package's public-facing read API. Before this
* suite landed the controllers in src/Http/Controllers/Api had zero direct
* test coverage these endpoints are what the storefront / frontend hits
* to render categories and product listings, so any regression on filter
* handling, scope behaviour or response shape silently broke real apps.
*
* Each test exercises a single endpoint with both the happy path and any
* type-sensitive query-string handling (pagination caps, boolean filters,
* featured / in_stock / category filtering) so the kind of `bool` / `int`
* cast bugs that motivated this round can't slip through unnoticed.
*/
class PublicApiTest extends TestCase
{
use RefreshDatabase;
// ------------------------------------------------------------------
// GET api/shop/products
// ------------------------------------------------------------------
#[Test]
public function products_index_returns_paginated_published_visible_products(): void
{
Product::factory()->count(3)->withPrices()->create([
'status' => 'published',
'is_visible' => true,
]);
// Should NOT appear: drafts, hidden, or trashed.
Product::factory()->create(['status' => 'draft', 'is_visible' => true]);
Product::factory()->create(['status' => 'published', 'is_visible' => false]);
$response = $this->getJson(route('shop.products.index'));
$response->assertOk();
$this->assertSame(3, $response->json('total'));
$this->assertCount(3, $response->json('data'));
}
#[Test]
public function products_index_honours_per_page_request_but_caps_to_max(): void
{
Product::factory()->count(8)->create([
'status' => 'published',
'is_visible' => true,
]);
// Within configured max — honoured verbatim.
$small = $this->getJson(route('shop.products.index', ['per_page' => 2]));
$this->assertSame(2, $small->json('per_page'));
$this->assertCount(2, $small->json('data'));
// Above the configured max — clamped down.
$max = (int) config('shop.pagination.max_per_page');
$clamped = $this->getJson(route('shop.products.index', ['per_page' => $max + 999]));
$this->assertLessThanOrEqual($max, (int) $clamped->json('per_page'));
}
#[Test]
public function products_index_filters_by_category_slug(): void
{
$catA = ProductCategory::create(['name' => 'A', 'slug' => 'cat-a', 'is_visible' => true]);
$catB = ProductCategory::create(['name' => 'B', 'slug' => 'cat-b', 'is_visible' => true]);
$inA = Product::factory()->create(['status' => 'published', 'is_visible' => true]);
$inB = Product::factory()->create(['status' => 'published', 'is_visible' => true]);
$inA->categories()->attach($catA->id);
$inB->categories()->attach($catB->id);
$response = $this->getJson(route('shop.products.index', ['category' => 'cat-a']));
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($inA->id, $ids);
$this->assertNotContains($inB->id, $ids);
}
#[Test]
public function products_index_filters_by_featured_flag(): void
{
$featured = Product::factory()->create([
'status' => 'published', 'is_visible' => true, 'featured' => true,
]);
$unfeatured = Product::factory()->create([
'status' => 'published', 'is_visible' => true, 'featured' => false,
]);
$response = $this->getJson(route('shop.products.index', ['featured' => 1]));
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($featured->id, $ids);
$this->assertNotContains($unfeatured->id, $ids);
}
#[Test]
public function products_index_filters_by_in_stock(): void
{
// Has ledger stock → considered in stock.
$orderable = Product::factory()->withStocks(5)->create([
'status' => 'published', 'is_visible' => true, 'manage_stock' => true,
]);
// manage_stock=true with no ledger entries → out of stock.
$depleted = Product::factory()->create([
'status' => 'published', 'is_visible' => true, 'manage_stock' => true,
]);
// manage_stock=false → always in stock by scope definition.
$unlimited = Product::factory()->create([
'status' => 'published', 'is_visible' => true, 'manage_stock' => false,
]);
$response = $this->getJson(route('shop.products.index', ['in_stock' => 1]));
$response->assertOk();
$ids = array_column($response->json('data'), 'id');
$this->assertContains($orderable->id, $ids);
$this->assertContains($unlimited->id, $ids);
$this->assertNotContains($depleted->id, $ids);
}
#[Test]
public function products_show_returns_published_visible_product_by_slug(): void
{
$product = Product::factory()->withPrices()->create([
'slug' => 'my-product',
'status' => 'published',
'is_visible' => true,
]);
$response = $this->getJson(route('shop.products.show', ['slug' => 'my-product']));
$response->assertOk();
// show() wraps the model under `data` and merges helper fields onto it.
$this->assertSame($product->id, $response->json('data.id'));
$this->assertArrayHasKey('current_price', $response->json('data'));
$this->assertArrayHasKey('on_sale', $response->json('data'));
}
#[Test]
public function products_show_404s_on_unknown_or_invisible_slug(): void
{
Product::factory()->create([
'slug' => 'hidden-product',
'status' => 'published',
'is_visible' => false,
]);
$this->getJson(route('shop.products.show', ['slug' => 'hidden-product']))
->assertNotFound();
$this->getJson(route('shop.products.show', ['slug' => 'does-not-exist']))
->assertNotFound();
}
// ------------------------------------------------------------------
// GET api/shop/categories
// ------------------------------------------------------------------
#[Test]
public function categories_index_returns_root_visible_categories_with_children(): void
{
$root = ProductCategory::create(['name' => 'Root', 'slug' => 'root', 'is_visible' => true, 'sort_order' => 1]);
ProductCategory::create([
'name' => 'Child', 'slug' => 'child', 'is_visible' => true, 'parent_id' => $root->id, 'sort_order' => 1,
]);
// Hidden root + child — must not surface.
ProductCategory::create(['name' => 'Hidden', 'slug' => 'hidden', 'is_visible' => false]);
$response = $this->getJson(route('shop.categories.index'));
$response->assertOk();
$data = $response->json('data');
$this->assertCount(1, $data, 'Only the visible root should be returned at the top level');
$this->assertSame('root', $data[0]['slug']);
$this->assertCount(1, $data[0]['children'], 'Child relation should be eager-loaded onto the root');
$this->assertSame('child', $data[0]['children'][0]['slug']);
}
#[Test]
public function categories_show_returns_category_by_slug_with_relations(): void
{
ProductCategory::create(['name' => 'C', 'slug' => 'c', 'is_visible' => true]);
$response = $this->getJson(route('shop.categories.show', ['slug' => 'c']));
$response->assertOk();
$this->assertSame('c', $response->json('data.slug'));
}
#[Test]
public function categories_show_404s_on_hidden_or_unknown(): void
{
ProductCategory::create(['name' => 'Hidden', 'slug' => 'hidden', 'is_visible' => false]);
$this->getJson(route('shop.categories.show', ['slug' => 'hidden']))->assertNotFound();
$this->getJson(route('shop.categories.show', ['slug' => 'nope']))->assertNotFound();
}
#[Test]
public function categories_tree_returns_category_tree(): void
{
ProductCategory::create(['name' => 'A', 'slug' => 'a', 'is_visible' => true]);
ProductCategory::create(['name' => 'B', 'slug' => 'b', 'is_visible' => true]);
$response = $this->getJson(route('shop.categories.tree'));
$response->assertOk();
$this->assertIsArray($response->json('data'));
}
}

View File

@ -14,9 +14,8 @@ class StockManagementTest extends TestCase
#[Test] #[Test]
public function it_detects_low_stock() public function it_detects_low_stock()
{ {
$product = Product::factory()->create([ $product = Product::factory()->withStocks(5)->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 5,
'low_stock_threshold' => 10, 'low_stock_threshold' => 10,
]); ]);
@ -36,15 +35,15 @@ class StockManagementTest extends TestCase
#[Test] #[Test]
public function it_marks_product_as_out_of_stock() public function it_marks_product_as_out_of_stock()
{ {
// manage_stock + no ledger entries IS the out-of-stock state under
// the ledger-only model — there are no `in_stock` / `stock_status`
// columns to fall back on anymore.
$product = Product::factory()->create([ $product = Product::factory()->create([
'manage_stock' => true, 'manage_stock' => true,
'stock_quantity' => 0,
'in_stock' => false,
'stock_status' => 'outofstock',
]); ]);
$this->assertFalse($product->in_stock); $this->assertFalse($product->isInStock());
$this->assertEquals('outofstock', $product->stock_status); $this->assertSame(0, $product->getAvailableStock());
} }
#[Test] #[Test]
@ -52,7 +51,6 @@ class StockManagementTest extends TestCase
{ {
$product = Product::factory()->create([ $product = Product::factory()->create([
'manage_stock' => false, 'manage_stock' => false,
'stock_quantity' => 0,
]); ]);
// When stock management is disabled, product should be considered in stock // When stock management is disabled, product should be considered in stock

View File

@ -0,0 +1,240 @@
<?php
namespace Blax\Shop\Tests\Unit;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use PHPUnit\Framework\Attributes\Test;
/**
* Type-safety regression cover for stock helpers.
*
* Past bugs that this suite is here to prevent from sneaking back in:
*
* - `abs()` called on a PDO-returned numeric string under strict_types,
* blowing up with "Argument #1 must be of type int|float, string given".
* - `(float)` / `(bool)` casts collapsing 0 / null / empty string into
* incorrect signals (e.g. "0 stock" false, but `null` false too,
* hiding a missing-config bug).
* - `isInStock()` / `getAvailableStock()` returning the wrong type
* (string instead of int/bool) after a column rename.
*
* Every assertion below pins down a *return type* in addition to a value
* so a future change that returns the right number-shaped string will
* still fail loudly.
*/
class StockTypeSafetyTest extends TestCase
{
use RefreshDatabase;
// ------------------------------------------------------------------
// Return-type contracts
// ------------------------------------------------------------------
#[Test]
public function get_available_stock_returns_int(): void
{
$product = Product::factory()->withStocks(10)->create();
$value = $product->getAvailableStock();
$this->assertIsInt($value, 'getAvailableStock must return int (PDO sum returns string)');
$this->assertSame(10, $value);
}
#[Test]
public function get_available_stock_returns_int_max_when_stock_unmanaged(): void
{
$product = Product::factory()->create(['manage_stock' => false]);
$value = $product->getAvailableStock();
$this->assertIsInt($value);
$this->assertSame(PHP_INT_MAX, $value);
}
#[Test]
public function get_available_stock_never_returns_negative_under_overclaim(): void
{
$product = Product::factory()->withStocks(2)->create();
// Overclaim past available — `max(0, ...)` must keep the result ≥ 0.
$product->stocks()->create([
'quantity' => -5, // claim 5 of 2
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->subHour(),
'expires_at' => Carbon::now()->addHour(),
]);
$value = $product->getAvailableStock();
$this->assertIsInt($value);
$this->assertGreaterThanOrEqual(0, $value, 'Must never be negative — UI/cart math depends on this');
}
#[Test]
public function is_in_stock_returns_strict_bool(): void
{
$stocked = Product::factory()->withStocks(1)->create();
$depleted = Product::factory()->create(['manage_stock' => true]); // no ledger
$unlimited = Product::factory()->create(['manage_stock' => false]);
// Strict type — not truthy/falsy, must be the literal bool values.
$this->assertSame(true, $stocked->isInStock());
$this->assertSame(false, $depleted->isInStock());
$this->assertSame(true, $unlimited->isInStock());
}
// ------------------------------------------------------------------
// abs() boundary — claim sums come back as negative numbers, all
// claim-related getters must report them as positive integers.
// ------------------------------------------------------------------
#[Test]
public function get_currently_claimed_stock_returns_positive_int(): void
{
$product = Product::factory()->withStocks(10)->create();
// Two active claims totalling -7 in the raw sum.
foreach ([-3, -4] as $qty) {
$product->stocks()->create([
'quantity' => $qty,
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->subHour(),
'expires_at' => Carbon::now()->addHour(),
]);
}
$value = $product->getCurrentlyClaimedStock();
$this->assertIsInt($value, 'abs() boundary — must return int, not numeric string');
$this->assertSame(7, $value, 'abs() must flip the sign — claims store negative quantities');
}
#[Test]
public function get_currently_claimed_stock_ignores_future_only_claims(): void
{
$product = Product::factory()->withStocks(10)->create();
// Future-only claim — getCurrentlyClaimedStock filters on
// `claimed_from <= now()` so this row must NOT contribute.
$product->stocks()->create([
'quantity' => -5,
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->addDays(7),
'expires_at' => Carbon::now()->addDays(10),
]);
$this->assertSame(0, $product->getCurrentlyClaimedStock());
}
#[Test]
public function get_active_and_planned_claimed_stock_includes_future_claims(): void
{
$product = Product::factory()->withStocks(10)->create();
$product->stocks()->create([
'quantity' => -5,
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->addDays(7),
'expires_at' => Carbon::now()->addDays(10),
]);
$value = $product->getActiveAndPlannedClaimedStock();
$this->assertIsInt($value);
$this->assertSame(5, $value);
}
#[Test]
public function get_future_claimed_stock_returns_positive_int(): void
{
$product = Product::factory()->withStocks(10)->create();
$product->stocks()->create([
'quantity' => -2,
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->addDays(7),
'expires_at' => Carbon::now()->addDays(10),
]);
$value = $product->getFutureClaimedStock();
$this->assertIsInt($value);
$this->assertSame(2, $value);
}
// ------------------------------------------------------------------
// Composite helper — physical_stock is "available + currently claimed"
// and historically blew up because the two terms ended up as
// (int + string), producing a string concatenation.
// ------------------------------------------------------------------
#[Test]
public function physical_stock_returns_int_sum_of_available_and_claimed(): void
{
$product = Product::factory()->withStocks(10)->create();
// Use the canonical claim API — it creates both the CLAIMED PENDING
// row AND the paired DECREASE COMPLETED row that subtracts from
// baseStock. Without the paired DECREASE the available count
// wouldn't drop, so this test also indirectly verifies that
// `claimStock()` keeps the ledger internally consistent.
$product->claimStock(
quantity: 4,
from: Carbon::now()->subHour(),
until: Carbon::now()->addHour(),
note: 'Type-safety test claim',
);
$this->assertSame(6, $product->getAvailableStock(), 'baseStock - paired DECREASE');
$this->assertSame(4, $product->getCurrentlyClaimedStock(), 'abs() of negative claim sum');
$value = $product->getPhysicalStock();
$this->assertIsInt($value);
$this->assertSame(10, $value, 'available (6) + claimed (4) = physical inventory (10)');
}
// ------------------------------------------------------------------
// isAvailableForBooking — the consumer of all the above; surfaces
// any sign / cast bug as a wrong availability decision.
// ------------------------------------------------------------------
#[Test]
public function is_available_for_booking_returns_strict_bool(): void
{
$product = Product::factory()->withStocks(1)->create();
$from = Carbon::now()->addDays(1);
$until = Carbon::now()->addDays(3);
$this->assertSame(true, $product->isAvailableForBooking($from, $until, 1));
$this->assertSame(false, $product->isAvailableForBooking($from, $until, 2));
}
#[Test]
public function is_available_for_booking_subtracts_overlapping_claims_correctly(): void
{
$product = Product::factory()->withStocks(2)->create();
// Pre-existing claim taking 1 unit for days 13.
$product->stocks()->create([
'quantity' => -1,
'type' => StockType::CLAIMED,
'status' => StockStatus::PENDING,
'claimed_from' => Carbon::now()->addDays(1),
'expires_at' => Carbon::now()->addDays(3),
]);
// 1 unit left for overlapping window — qty=1 OK, qty=2 NOT.
$this->assertTrue(
$product->isAvailableForBooking(Carbon::now()->addDays(2), Carbon::now()->addDays(4), 1)
);
$this->assertFalse(
$product->isAvailableForBooking(Carbon::now()->addDays(2), Carbon::now()->addDays(4), 2)
);
}
}