BF edgecases, R structure, A tests
This commit is contained in:
parent
ab8ea6afec
commit
0cfbdf221d
|
|
@ -174,4 +174,21 @@ return [
|
|||
'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),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -48,9 +48,8 @@ class ProductFactory extends Factory
|
|||
'is_visible' => true,
|
||||
'featured' => false,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => $this->faker->numberBetween(0, 100),
|
||||
'in_stock' => true,
|
||||
'stock_status' => 'instock',
|
||||
// Stock counts live in the ProductStock ledger — use the
|
||||
// ->withStocks(int) state to seed an initial INCREASE entry.
|
||||
'published_at' => now(),
|
||||
'meta' => json_encode(new \stdClass()),
|
||||
];
|
||||
|
|
@ -58,11 +57,10 @@ class ProductFactory extends Factory
|
|||
|
||||
public function outOfStock(): static
|
||||
{
|
||||
return $this->state([
|
||||
'stock_quantity' => 0,
|
||||
'in_stock' => false,
|
||||
'stock_status' => 'outofstock',
|
||||
]);
|
||||
// manage_stock=true + no ledger entries → isInStock() returns false,
|
||||
// getAvailableStock() returns 0. That IS the out-of-stock state under
|
||||
// the ledger-only model — no extra columns required.
|
||||
return $this->state(['manage_stock' => true]);
|
||||
}
|
||||
|
||||
public function variable(): static
|
||||
|
|
|
|||
|
|
@ -25,10 +25,13 @@ return new class extends Migration
|
|||
$table->timestamp('sale_start')->nullable();
|
||||
$table->timestamp('sale_end')->nullable();
|
||||
$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->boolean('in_stock')->default(true);
|
||||
$table->string('stock_status')->default('instock'); // instock, outofstock, onbackorder
|
||||
// Live stock state (in-stock?, status) is computed from the
|
||||
// ProductStock ledger — see HasStocks::isInStock / scopeInStock.
|
||||
// No denormalised columns on products.
|
||||
$table->decimal('weight', 10, 2)->nullable();
|
||||
$table->decimal('length', 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');
|
||||
}
|
||||
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')) {
|
||||
$table->timestamp('published_at')->nullable()->after('status');
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -228,8 +228,10 @@ class ShopAddExampleProducts extends Command
|
|||
} elseif ($type === ProductType::POOL->value) {
|
||||
$this->addPoolItemsForHotel($product, $productData);
|
||||
} elseif ($type === ProductType::BOOKING->value) {
|
||||
// Bookings need stock to be bookable
|
||||
if ($product->stock_quantity === 0) {
|
||||
// Bookings need stock to be bookable. Read the canonical live
|
||||
// count from the ledger (getAvailableStock) — no `stock_quantity`
|
||||
// column to consult anymore.
|
||||
if ($product->getAvailableStock() === 0) {
|
||||
$product->increaseStock($productData['stock'] ?? 10);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,10 +13,14 @@ class ProductController extends Controller
|
|||
{
|
||||
$productModel = config('shop.models.product');
|
||||
|
||||
$perPage = min(
|
||||
request('per_page', config('shop.pagination.per_page')),
|
||||
config('shop.pagination.max_per_page')
|
||||
);
|
||||
// Honour the request, fall back to the configured default, clamp to
|
||||
// the configured max. Defaults are applied here too so a missing
|
||||
// `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()
|
||||
->published()
|
||||
|
|
@ -59,7 +63,6 @@ class ProductController extends Controller
|
|||
[
|
||||
'current_price' => $product->getCurrentPrice(),
|
||||
'on_sale' => $product->isOnSale(),
|
||||
'average_rating' => $product->getAverageRating(),
|
||||
]
|
||||
),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -302,7 +302,10 @@ class ProductStock extends Model
|
|||
DB::table('product_stock_logs')->insert([
|
||||
'product_id' => $this->product_id,
|
||||
'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,
|
||||
'note' => $this->note,
|
||||
'reference_type' => $this->reference_type,
|
||||
|
|
|
|||
|
|
@ -127,7 +127,12 @@ trait MayBePoolProduct
|
|||
public function getPoolTotalCapacity(): int
|
||||
{
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -32,10 +32,9 @@ class BookingFeatureTest extends TestCase
|
|||
'slug' => 'hotel-room',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0,
|
||||
]);
|
||||
|
||||
// Initialize stock
|
||||
// Seed stock via the ledger (the canonical source).
|
||||
$this->bookingProduct->increaseStock(10);
|
||||
|
||||
// Create a price
|
||||
|
|
|
|||
|
|
@ -34,10 +34,9 @@ class BookingPerMinutePricingTest extends TestCase
|
|||
'slug' => 'conference-room',
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0,
|
||||
]);
|
||||
|
||||
// Initialize stock
|
||||
// Seed stock via the ledger (the canonical source).
|
||||
$this->bookingProduct->increaseStock(10);
|
||||
|
||||
// Create a price: $100.00 per day (24 hours)
|
||||
|
|
|
|||
|
|
@ -470,10 +470,16 @@ class CartDateManagementTest extends TestCase
|
|||
#[Test]
|
||||
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 1–3". 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,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 1,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
|
|
@ -481,19 +487,28 @@ class CartDateManagementTest extends TestCase
|
|||
'purchasable_type' => Product::class,
|
||||
'type' => PriceType::RECURRING,
|
||||
'is_default' => true,
|
||||
|
||||
]);
|
||||
|
||||
// Pre-existing claim that locks the unit for days 1–3.
|
||||
$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();
|
||||
$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();
|
||||
$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');
|
||||
|
|
@ -502,10 +517,9 @@ class CartDateManagementTest extends TestCase
|
|||
#[Test]
|
||||
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,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 1,
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
|
|
@ -541,10 +555,12 @@ class CartDateManagementTest extends TestCase
|
|||
#[Test]
|
||||
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([
|
||||
'type' => ProductType::BOOKING,
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0, // No stock available
|
||||
]);
|
||||
|
||||
$price = ProductPrice::factory()->create([
|
||||
|
|
|
|||
|
|
@ -298,10 +298,9 @@ class PurchaseFlowTest extends TestCase
|
|||
public function user_cannot_add_out_of_stock_product_to_cart()
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
// No ledger entries seeded — getAvailableStock() returns 0.
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0,
|
||||
'in_stock' => false,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,8 @@ class StockManagementTest extends TestCase
|
|||
#[Test]
|
||||
public function it_detects_low_stock()
|
||||
{
|
||||
$product = Product::factory()->create([
|
||||
$product = Product::factory()->withStocks(5)->create([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 5,
|
||||
'low_stock_threshold' => 10,
|
||||
]);
|
||||
|
||||
|
|
@ -36,15 +35,15 @@ class StockManagementTest extends TestCase
|
|||
#[Test]
|
||||
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([
|
||||
'manage_stock' => true,
|
||||
'stock_quantity' => 0,
|
||||
'in_stock' => false,
|
||||
'stock_status' => 'outofstock',
|
||||
]);
|
||||
|
||||
$this->assertFalse($product->in_stock);
|
||||
$this->assertEquals('outofstock', $product->stock_status);
|
||||
$this->assertFalse($product->isInStock());
|
||||
$this->assertSame(0, $product->getAvailableStock());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
|
|
@ -52,7 +51,6 @@ class StockManagementTest extends TestCase
|
|||
{
|
||||
$product = Product::factory()->create([
|
||||
'manage_stock' => false,
|
||||
'stock_quantity' => 0,
|
||||
]);
|
||||
|
||||
// When stock management is disabled, product should be considered in stock
|
||||
|
|
|
|||
|
|
@ -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 1–3.
|
||||
$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)
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue