diff --git a/config/shop.php b/config/shop.php index c3b7192..ef7e805 100644 --- a/config/shop.php +++ b/config/shop.php @@ -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), + ], + ]; diff --git a/database/factories/ProductFactory.php b/database/factories/ProductFactory.php index 1b50751..88a7d20 100644 --- a/database/factories/ProductFactory.php +++ b/database/factories/ProductFactory.php @@ -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 diff --git a/database/migrations/2025_01_01_000001_create_blax_shop_tables.php b/database/migrations/2025_01_01_000001_create_blax_shop_tables.php index e6c5c4d..b0a6cf5 100644 --- a/database/migrations/2025_01_01_000001_create_blax_shop_tables.php +++ b/database/migrations/2025_01_01_000001_create_blax_shop_tables.php @@ -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'); diff --git a/database/migrations/2026_01_01_000000_drop_stock_quantity_from_products.php b/database/migrations/2026_01_01_000000_drop_stock_quantity_from_products.php new file mode 100644 index 0000000..db7dcbc --- /dev/null +++ b/database/migrations/2026_01_01_000000_drop_stock_quantity_from_products.php @@ -0,0 +1,54 @@ +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'); + }); + } + } +}; diff --git a/database/migrations/2026_01_01_000001_drop_in_stock_and_stock_status_from_products.php b/database/migrations/2026_01_01_000001_drop_in_stock_and_stock_status_from_products.php new file mode 100644 index 0000000..d7f1e6c --- /dev/null +++ b/database/migrations/2026_01_01_000001_drop_in_stock_and_stock_status_from_products.php @@ -0,0 +1,66 @@ +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'); + } + }); + } +}; diff --git a/src/Console/Commands/ShopAddExampleProducts.php b/src/Console/Commands/ShopAddExampleProducts.php index ca3264b..4ff40f1 100644 --- a/src/Console/Commands/ShopAddExampleProducts.php +++ b/src/Console/Commands/ShopAddExampleProducts.php @@ -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); } } diff --git a/src/Exceptions/CartNotReadyException.php b/src/Exceptions/CartNotReadyException.php deleted file mode 100644 index 36fbf6a..0000000 --- a/src/Exceptions/CartNotReadyException.php +++ /dev/null @@ -1,15 +0,0 @@ -published() @@ -59,7 +63,6 @@ class ProductController extends Controller [ 'current_price' => $product->getCurrentPrice(), 'on_sale' => $product->isOnSale(), - 'average_rating' => $product->getAverageRating(), ] ), ]); diff --git a/src/Models/ProductStock.php b/src/Models/ProductStock.php index ec6630a..482dd7c 100644 --- a/src/Models/ProductStock.php +++ b/src/Models/ProductStock.php @@ -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, diff --git a/src/Traits/MayBePoolProduct.php b/src/Traits/MayBePoolProduct.php index 952fc75..a363eee 100644 --- a/src/Traits/MayBePoolProduct.php +++ b/src/Traits/MayBePoolProduct.php @@ -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; diff --git a/tests/Feature/Booking/BookingFeatureTest.php b/tests/Feature/Booking/BookingFeatureTest.php index 51674c7..19f88ca 100644 --- a/tests/Feature/Booking/BookingFeatureTest.php +++ b/tests/Feature/Booking/BookingFeatureTest.php @@ -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 diff --git a/tests/Feature/Booking/BookingPerMinutePricingTest.php b/tests/Feature/Booking/BookingPerMinutePricingTest.php index 1dbc5ec..ff8ef6c 100644 --- a/tests/Feature/Booking/BookingPerMinutePricingTest.php +++ b/tests/Feature/Booking/BookingPerMinutePricingTest.php @@ -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) diff --git a/tests/Feature/Cart/CartDateManagementTest.php b/tests/Feature/Cart/CartDateManagementTest.php index 68276df..7b9494f 100644 --- a/tests/Feature/Cart/CartDateManagementTest.php +++ b/tests/Feature/Cart/CartDateManagementTest.php @@ -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([ diff --git a/tests/Feature/Checkout/PurchaseFlowTest.php b/tests/Feature/Checkout/PurchaseFlowTest.php index 7722d6c..cb9e032 100644 --- a/tests/Feature/Checkout/PurchaseFlowTest.php +++ b/tests/Feature/Checkout/PurchaseFlowTest.php @@ -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); diff --git a/tests/Feature/Http/PublicApiTest.php b/tests/Feature/Http/PublicApiTest.php new file mode 100644 index 0000000..152ac7c --- /dev/null +++ b/tests/Feature/Http/PublicApiTest.php @@ -0,0 +1,220 @@ +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')); + } +} diff --git a/tests/Unit/StockManagementTest.php b/tests/Unit/StockManagementTest.php index c509118..957a9b5 100644 --- a/tests/Unit/StockManagementTest.php +++ b/tests/Unit/StockManagementTest.php @@ -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 diff --git a/tests/Unit/StockTypeSafetyTest.php b/tests/Unit/StockTypeSafetyTest.php new file mode 100644 index 0000000..435a56d --- /dev/null +++ b/tests/Unit/StockTypeSafetyTest.php @@ -0,0 +1,240 @@ +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) + ); + } +}