diff --git a/src/Console/Commands/ShopStocksCommand.php b/src/Console/Commands/ShopStocksCommand.php index 022b53f..25e7882 100644 --- a/src/Console/Commands/ShopStocksCommand.php +++ b/src/Console/Commands/ShopStocksCommand.php @@ -37,7 +37,7 @@ class ShopStocksCommand extends Command } $rows = $products->map(function (Product $product): array { - $assigned = $product->manage_stock ? (int) $product->getMaxStocksAttribute() : null; + $assigned = $product->manage_stock ? $this->assignedCapacity($product) : null; $used = $product->manage_stock ? $this->totalUsed($product) : null; $available = $product->getAvailableStock(); $claimed = $product->getCurrentlyClaimedStock(); @@ -88,7 +88,7 @@ class ShopStocksCommand extends Command return self::SUCCESS; } - $assigned = (int) $product->getMaxStocksAttribute(); + $assigned = $this->assignedCapacity($product); $used = $this->totalUsed($product); $available = $product->getAvailableStock(); $currentClaims = $product->getCurrentlyClaimedStock(); @@ -146,6 +146,23 @@ class ShopStocksCommand extends Command ); } + /** + * Physical inventory the operator should see as "Assigned" — i.e. how many + * copies the business actually owns. + * + * For non-loanable products this is just `getMaxStocksAttribute()` (sum of + * INCREASE + RETURN entries). For loanable products that calc inflates + * after every borrow→return cycle because the host's restock fires a + * fresh INCREASE row; MayBeLoanableProduct's `total_quantity` accessor + * sidesteps that by computing "available + active loans" instead. The + * trait is mixed into Product unconditionally, so it's safe to consult + * here for every product type. + */ + private function assignedCapacity(Product $product): int + { + return (int) $product->total_quantity; + } + /** * @param list $boxes */ diff --git a/src/Console/Commands/ShopTestActionCommand.php b/src/Console/Commands/ShopTestActionCommand.php index 2ae5387..667c4d7 100644 --- a/src/Console/Commands/ShopTestActionCommand.php +++ b/src/Console/Commands/ShopTestActionCommand.php @@ -25,7 +25,7 @@ class ShopTestActionCommand extends Command return 1; } - $this->info("Testing action: {$action->action_class}"); + $this->info("Testing action: {$action->class}"); $this->info("Product: {$action->product->name} (ID: {$action->product_id})"); $this->info("Event: {$action->event}"); @@ -36,8 +36,14 @@ class ShopTestActionCommand extends Command try { if ($this->option('sync')) { - $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); - $action_job = $namespace . '\\' . $action->action_class; + // Resolve the action class. If a fully-qualified name is set + // on `class`, use it as-is; otherwise prefix the configured + // actions namespace so short names still resolve. + $actionClass = $action->class; + if (! str_contains($actionClass, '\\')) { + $namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction'); + $actionClass = $namespace . '\\' . $actionClass; + } $params = [ 'product' => $action->product, @@ -46,10 +52,22 @@ class ShopTestActionCommand extends Command ...($action->parameters ?? []), ]; - (new $action_job(...$params))->handle(); + $instance = new $actionClass(...$params); + if (method_exists($instance, 'handle')) { + $instance->handle(); + } elseif (is_callable($instance)) { + $instance(); + } else { + throw new \RuntimeException("Action class {$actionClass} is neither callable nor has a handle() method."); + } $this->info('Action executed synchronously.'); } else { - $action->execute($action->product, null, []); + // Run the action through the package's normal runner — which + // honours defer/method/parameters and writes a ProductActionRun + // row. ProductAction::callForProduct() looks up matching + // actions by event; passing the action's own first event + // guarantees this single action fires. + ProductAction::callForProduct($action->product, $action->event, null, []); $this->info('Action dispatched to queue.'); } diff --git a/src/Console/Commands/ShopToggleActionCommand.php b/src/Console/Commands/ShopToggleActionCommand.php index 35d2d87..6cfe393 100644 --- a/src/Console/Commands/ShopToggleActionCommand.php +++ b/src/Console/Commands/ShopToggleActionCommand.php @@ -26,20 +26,25 @@ class ShopToggleActionCommand extends Command return 1; } + // The ProductAction column is `active`, not `enabled` — the old code + // wrote to a non-existent attribute, which silently no-op'd (or threw + // a SQL error in strict mode). The user-facing verbs stay + // "enabled"/"disabled" since that's what operators understand, but the + // column we touch is `active`. if ($this->option('enable')) { - $action->enabled = true; + $action->active = true; $status = 'enabled'; } elseif ($this->option('disable')) { - $action->enabled = false; + $action->active = false; $status = 'disabled'; } else { - $action->enabled = !$action->enabled; - $status = $action->enabled ? 'enabled' : 'disabled'; + $action->active = ! $action->active; + $status = $action->active ? 'enabled' : 'disabled'; } $action->save(); - $this->info("Action #{$action->id} ({$action->action_class}) has been {$status}."); + $this->info("Action #{$action->id} ({$action->class}) has been {$status}."); return 0; } diff --git a/src/Traits/HasStocks.php b/src/Traits/HasStocks.php index 43b7d5a..4d54af8 100644 --- a/src/Traits/HasStocks.php +++ b/src/Traits/HasStocks.php @@ -575,6 +575,48 @@ trait HasStocks return $this->getAvailableStock() <= $this->low_stock_threshold; } + /** + * When does the next unit become available? Returns null when stock is + * already free right now. + * + * Considers both ends of the package's two-track availability model: + * - Active loans / bookings — earliest `until` on a not-yet-returned + * {@see \Blax\Shop\Models\ProductPurchase} via the HasLoanLifecycle + * `activeLoans()` scope. + * - Active stock claims — earliest `expires_at` on a PENDING/CLAIMED + * {@see \Blax\Shop\Models\ProductStock} that hasn't already lapsed. + * + * The minimum of those candidates is when *something* frees up. Hosts can + * render this directly (`$product->nextAvailableAt()?->toIso8601String()`) + * instead of redefining the same query in every Resource. + */ + public function nextAvailableAt(): ?Carbon + { + if ($this->getAvailableStock() > 0) { + return null; + } + + $candidates = []; + + $nextLoanEnd = $this->purchases()->activeLoans()->min('until'); + if ($nextLoanEnd) { + $candidates[] = Carbon::parse($nextLoanEnd); + } + + $nextClaimEnd = $this->stocks() + ->withoutGlobalScope('willExpire') + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value) + ->whereNotNull('expires_at') + ->where('expires_at', '>', now()) + ->min('expires_at'); + if ($nextClaimEnd) { + $candidates[] = Carbon::parse($nextClaimEnd); + } + + return empty($candidates) ? null : Carbon::parse(min($candidates)); + } + /** * Get active claims for this product * diff --git a/tests/Feature/CommandActionsTest.php b/tests/Feature/CommandActionsTest.php new file mode 100644 index 0000000..8c1a96e --- /dev/null +++ b/tests/Feature/CommandActionsTest.php @@ -0,0 +1,238 @@ +action_class" was always blank. + * 2. ShopTestActionCommand referenced `$action->action_class` (column is + * `class`) and called a non-existent `$action->execute(...)` method in + * the default (queued) path, so any non-`--sync` invocation crashed + * with "Call to undefined method". + * + * The tests below pin down the correct post-fix behaviour. + */ +class CommandActionsTest extends TestCase +{ + use RefreshDatabase; + + private Product $product; + + protected function setUp(): void + { + parent::setUp(); + + $this->product = Product::create([ + 'name' => 'Actionable', + 'sku' => 'ACT-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + } + + private function action(bool $active = true, string $class = TestActionListener::class): ProductAction + { + return ProductAction::create([ + 'product_id' => $this->product->id, + 'events' => ['purchased'], + 'class' => $class, + 'method' => null, + 'defer' => false, + 'active' => $active, + ]); + } + + /* ───────────────────────── shop:toggle-action ─────────────────────── */ + + #[Test] + public function toggle_flips_active_when_no_flag_is_given(): void + { + $action = $this->action(active: true); + + Artisan::call(ShopToggleActionCommand::class, ['action-id' => $action->id]); + $output = Artisan::output(); + + $this->assertFalse((bool) $action->fresh()->active, 'active was true → false'); + $this->assertStringContainsString('disabled', $output); + + Artisan::call(ShopToggleActionCommand::class, ['action-id' => $action->id]); + $this->assertTrue((bool) $action->fresh()->active, 'second toggle flips back to true'); + } + + #[Test] + public function enable_flag_force_activates_the_action(): void + { + $action = $this->action(active: false); + + Artisan::call(ShopToggleActionCommand::class, [ + 'action-id' => $action->id, + '--enable' => true, + ]); + + $this->assertTrue((bool) $action->fresh()->active); + } + + #[Test] + public function disable_flag_force_deactivates_the_action(): void + { + $action = $this->action(active: true); + + Artisan::call(ShopToggleActionCommand::class, [ + 'action-id' => $action->id, + '--disable' => true, + ]); + + $this->assertFalse((bool) $action->fresh()->active); + } + + #[Test] + public function toggle_reports_an_error_for_an_unknown_action_id(): void + { + $exit = Artisan::call(ShopToggleActionCommand::class, [ + 'action-id' => 'no-such-id', + ]); + $output = Artisan::output(); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('not found', $output); + } + + #[Test] + public function toggle_summary_carries_the_actions_class_name(): void + { + // Regression: the command used to read $action->action_class (which + // doesn't exist as a column or accessor), so the summary line printed + // an empty class name. Verify the real `class` column is what surfaces. + $action = $this->action(active: true, class: 'App\\Foo\\MyAction'); + + Artisan::call(ShopToggleActionCommand::class, ['action-id' => $action->id]); + $output = Artisan::output(); + + $this->assertStringContainsString('App\\Foo\\MyAction', $output); + } + + /* ───────────────────────── shop:test-action ───────────────────────── */ + + #[Test] + public function test_action_errors_out_for_unknown_id(): void + { + $exit = Artisan::call(ShopTestActionCommand::class, ['action-id' => 'no-such-id']); + $output = Artisan::output(); + + $this->assertSame(1, $exit); + $this->assertStringContainsString('not found', $output); + } + + #[Test] + public function test_action_runs_the_action_and_writes_an_action_run_row(): void + { + // Default (non-sync) path: the command must dispatch the action via + // the package's normal action runner so a ProductActionRun row is + // created. The test action below is sync (defer=false) and just sets + // a flag, but the ProductActionRun row is the operator-visible signal + // that the run happened. + TestActionListener::$invocations = 0; + $action = $this->action(active: true); + + // The command asks for confirmation; "no" returns early. We need + // "yes" — Artisan::call accepts a callable but the simplest path + // is to set --no-interaction so confirm() defaults to false… + // Instead, mock the prompt via withAnswers when available; otherwise + // patch the question by passing --no-interaction is wrong (defaults + // to false). Use the alternative: artisan call with answers. + $this->artisan(ShopTestActionCommand::class, ['action-id' => $action->id]) + ->expectsConfirmation('Do you want to proceed?', 'yes') + ->expectsOutputToContain('Testing action: '.TestActionListener::class) + ->expectsOutputToContain('completed successfully') + ->assertExitCode(0); + + $this->assertGreaterThan(0, TestActionListener::$invocations, 'the action class was invoked'); + $this->assertGreaterThan( + 0, + ProductActionRun::where('action_id', $action->id)->count(), + 'a ProductActionRun row records the execution', + ); + } + + #[Test] + public function test_action_sync_flag_calls_the_action_class_directly(): void + { + // --sync bypasses the queue and instantiates the action class with the + // standard (product, productPurchase, event, ...) signature. The test + // listener counts invocations so we can assert it actually ran. + TestActionListener::$invocations = 0; + $action = $this->action(active: true); + + $this->artisan(ShopTestActionCommand::class, [ + 'action-id' => $action->id, + '--sync' => true, + ]) + ->expectsConfirmation('Do you want to proceed?', 'yes') + ->expectsOutputToContain('Action executed synchronously.') + ->assertExitCode(0); + + $this->assertSame(1, TestActionListener::$invocations); + } + + #[Test] + public function test_action_cancellation_short_circuits_with_zero_exit(): void + { + // Declining the confirmation must return early without executing the + // action — proves that the prompt is gating execution properly. + TestActionListener::$invocations = 0; + $action = $this->action(active: true); + + $this->artisan(ShopTestActionCommand::class, ['action-id' => $action->id]) + ->expectsConfirmation('Do you want to proceed?', 'no') + ->expectsOutputToContain('Test cancelled.') + ->assertExitCode(0); + + $this->assertSame(0, TestActionListener::$invocations); + $this->assertSame(0, ProductActionRun::where('action_id', $action->id)->count()); + } +} + +/** + * Trivial in-memory action class used by the test-action coverage. The + * package's runner instantiates it with named params (product, productPurchase, + * event, …extras); the constructor accepts everything via variadic-named + * arguments so the package can call ->__invoke() to fire it. + */ +class TestActionListener +{ + public static int $invocations = 0; + + public function __construct( + public ?\Blax\Shop\Models\Product $product = null, + public ?\Blax\Shop\Models\ProductPurchase $productPurchase = null, + public ?string $event = null, + ) {} + + public function __invoke(): void + { + self::$invocations++; + } +} diff --git a/tests/Feature/CommandCleanupCartsTest.php b/tests/Feature/CommandCleanupCartsTest.php new file mode 100644 index 0000000..57437b5 --- /dev/null +++ b/tests/Feature/CommandCleanupCartsTest.php @@ -0,0 +1,173 @@ + 'sess-stale-'.uniqid(), + 'status' => CartStatus::ACTIVE, + ]); + // Backdate last_activity_at past the default 60-minute expiration. + $cart->forceFill(['last_activity_at' => now()->subHours(3)])->saveQuietly(); + + return $cart; + } + + /** A cart old enough for deletion (past `deletion_hours`). */ + private function deletionCandidateCart(?CartStatus $status = CartStatus::EXPIRED): Cart + { + $cart = Cart::create([ + 'session_id' => 'sess-old-'.uniqid(), + 'status' => $status, + ]); + $cart->forceFill(['last_activity_at' => now()->subDays(2)])->saveQuietly(); + + return $cart; + } + + #[Test] + public function default_run_with_force_expires_and_deletes_in_one_pass(): void + { + $stale = $this->staleActiveCart(); + $old = $this->deletionCandidateCart(); + + $exit = Artisan::call(ShopCleanupCartsCommand::class, ['--force' => true]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Cart Cleanup', $output); + $this->assertStringContainsString('Carts expired:', $output); + $this->assertStringContainsString('Carts deleted:', $output); + + // Old cart is gone, stale cart was expired in place. + $this->assertNull(Cart::find($old->id)); + $refreshed = Cart::find($stale->id); + // The stale cart was eligible for both expire AND delete (past 2 days) + // — but it isn't past deletion_hours here (only 3 hours of inactivity), + // so it should survive as EXPIRED. + $this->assertNotNull($refreshed); + $this->assertSame(CartStatus::EXPIRED, $refreshed->status); + } + + #[Test] + public function expire_flag_does_not_delete(): void + { + $stale = $this->staleActiveCart(); + $old = $this->deletionCandidateCart(); + + Artisan::call(ShopCleanupCartsCommand::class, ['--expire' => true]); + + // Stale cart status flipped to EXPIRED, but the old cart that was + // eligible for deletion is still present. + $this->assertSame(CartStatus::EXPIRED, Cart::find($stale->id)->status); + $this->assertNotNull(Cart::find($old->id)); + } + + #[Test] + public function delete_flag_does_not_expire(): void + { + $stale = $this->staleActiveCart(); + $old = $this->deletionCandidateCart(); + + Artisan::call(ShopCleanupCartsCommand::class, [ + '--delete' => true, + '--force' => true, + ]); + + // The active stale cart is still ACTIVE (not expired this run). + $this->assertSame(CartStatus::ACTIVE, Cart::find($stale->id)->status); + // Old cart was deleted. + $this->assertNull(Cart::find($old->id)); + } + + #[Test] + public function dry_run_reports_intent_without_mutating_anything(): void + { + $stale = $this->staleActiveCart(); + $old = $this->deletionCandidateCart(); + + Artisan::call(ShopCleanupCartsCommand::class, ['--dry-run' => true]); + $output = Artisan::output(); + + $this->assertStringContainsString('[DRY RUN]', $output); + + // No mutations: stale is still ACTIVE, old is still present. + $this->assertSame(CartStatus::ACTIVE, Cart::find($stale->id)->status); + $this->assertNotNull(Cart::find($old->id)); + } + + #[Test] + public function fresh_carts_are_untouched(): void + { + // A brand-new cart created in this test is well under any threshold. + $fresh = Cart::create([ + 'session_id' => 'sess-fresh-1', + 'status' => CartStatus::ACTIVE, + ]); + + Artisan::call(ShopCleanupCartsCommand::class, ['--force' => true]); + + $refreshed = Cart::find($fresh->id); + $this->assertNotNull($refreshed); + $this->assertSame(CartStatus::ACTIVE, $refreshed->status); + } + + #[Test] + public function converted_carts_are_never_deleted(): void + { + // Even when wildly stale, a CONVERTED cart must survive cleanup — + // it's the historical record of a completed checkout. + $converted = Cart::create([ + 'session_id' => 'sess-converted-1', + 'status' => CartStatus::CONVERTED, + ]); + $converted->forceFill([ + 'last_activity_at' => now()->subDays(10), + 'converted_at' => now()->subDays(10), + ])->saveQuietly(); + + Artisan::call(ShopCleanupCartsCommand::class, ['--force' => true]); + + $this->assertNotNull(Cart::find($converted->id)); + $this->assertSame(CartStatus::CONVERTED, Cart::find($converted->id)->status); + } + + #[Test] + public function summary_includes_count_of_carts_expired_and_deleted(): void + { + $this->staleActiveCart(); + $this->staleActiveCart(); + $this->deletionCandidateCart(); + + Artisan::call(ShopCleanupCartsCommand::class, ['--force' => true]); + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/Carts expired:\s*2\b/', $output); + $this->assertMatchesRegularExpression('/Carts deleted:\s*1\b/', $output); + } +} diff --git a/tests/Feature/CommandListProductsTest.php b/tests/Feature/CommandListProductsTest.php new file mode 100644 index 0000000..21a96d5 --- /dev/null +++ b/tests/Feature/CommandListProductsTest.php @@ -0,0 +1,134 @@ + 'Product '.uniqid(), + 'sku' => 'P-'.uniqid(), + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ], $overrides)); + } + + #[Test] + public function unfiltered_call_lists_every_product_with_total_count(): void + { + $this->newProduct(['name' => 'Alpha']); + $this->newProduct(['name' => 'Bravo']); + + $exit = Artisan::call(ShopListProductsCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Alpha', $output); + $this->assertStringContainsString('Bravo', $output); + $this->assertStringContainsString('Total products: 2', $output); + } + + #[Test] + public function empty_catalogue_renders_the_empty_state_message(): void + { + $exit = Artisan::call(ShopListProductsCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No products found.', $output); + } + + #[Test] + public function status_filter_narrows_results(): void + { + $this->newProduct(['name' => 'Live Product', 'status' => ProductStatus::PUBLISHED]); + $this->newProduct(['name' => 'Draft Product', 'status' => ProductStatus::DRAFT]); + + Artisan::call(ShopListProductsCommand::class, ['--status' => 'draft']); + $output = Artisan::output(); + + $this->assertStringContainsString('Draft Product', $output); + $this->assertStringNotContainsString('Live Product', $output); + $this->assertStringContainsString('Total products: 1', $output); + } + + #[Test] + public function visible_filter_and_hidden_filter_are_mutually_exclusive(): void + { + $this->newProduct(['name' => 'On Shelf', 'is_visible' => true]); + $this->newProduct(['name' => 'Off Shelf', 'is_visible' => false]); + + Artisan::call(ShopListProductsCommand::class, ['--visible' => true]); + $visibleOutput = Artisan::output(); + $this->assertStringContainsString('On Shelf', $visibleOutput); + $this->assertStringNotContainsString('Off Shelf', $visibleOutput); + + Artisan::call(ShopListProductsCommand::class, ['--hidden' => true]); + $hiddenOutput = Artisan::output(); + $this->assertStringContainsString('Off Shelf', $hiddenOutput); + $this->assertStringNotContainsString('On Shelf', $hiddenOutput); + } + + #[Test] + public function type_filter_narrows_by_product_type(): void + { + $this->newProduct(['name' => 'Simple One', 'type' => ProductType::SIMPLE]); + $this->newProduct(['name' => 'Loanable One', 'type' => ProductType::LOANABLE]); + + Artisan::call(ShopListProductsCommand::class, ['--type' => 'loanable']); + $output = Artisan::output(); + + $this->assertStringContainsString('Loanable One', $output); + $this->assertStringNotContainsString('Simple One', $output); + } + + #[Test] + public function with_actions_flag_adds_an_actions_column_with_per_product_count(): void + { + $a = $this->newProduct(['name' => 'Has Actions']); + $b = $this->newProduct(['name' => 'Bare']); + + ProductAction::create([ + 'product_id' => $a->id, + 'events' => ['purchased'], + 'class' => 'App\\Welcome', + 'active' => true, + ]); + ProductAction::create([ + 'product_id' => $a->id, + 'events' => ['refunded'], + 'class' => 'App\\Apology', + 'active' => true, + ]); + + Artisan::call(ShopListProductsCommand::class, ['--with-actions' => true]); + $output = Artisan::output(); + + // The "Actions" column appears in the header. + $this->assertStringContainsString('Actions', $output); + // And the count for "Has Actions" must be 2. + $this->assertMatchesRegularExpression('/Has Actions.*?\b2\b/', $output); + // "Bare" still appears, with 0 actions. + $this->assertStringContainsString('Bare', $output); + } +} diff --git a/tests/Feature/CommandListPurchasesTest.php b/tests/Feature/CommandListPurchasesTest.php new file mode 100644 index 0000000..ed8eb66 --- /dev/null +++ b/tests/Feature/CommandListPurchasesTest.php @@ -0,0 +1,173 @@ +book = Product::create([ + 'name' => 'Hyperion', + 'sku' => 'HYP-LP', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + $this->alice = User::factory()->create(['name' => 'Alice']); + $this->bob = User::factory()->create(['name' => 'Bob']); + } + + private function purchase(User $purchaser, PurchaseStatus $status, int $amountPaid = 0): ProductPurchase + { + return ProductPurchase::create([ + 'purchasable_id' => $this->book->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => $purchaser->id, + 'purchaser_type' => User::class, + 'quantity' => 1, + 'amount' => 2500, + 'amount_paid' => $amountPaid, + 'status' => $status, + ]); + } + + #[Test] + public function empty_state_shows_the_no_purchases_message(): void + { + $exit = Artisan::call(ShopListPurchasesCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No purchases found.', $output); + } + + #[Test] + public function unfiltered_call_lists_every_purchase_with_purchasable_and_purchaser_names(): void + { + $this->purchase($this->alice, PurchaseStatus::COMPLETED, 2500); + $this->purchase($this->bob, PurchaseStatus::PENDING); + + Artisan::call(ShopListPurchasesCommand::class); + $output = Artisan::output(); + + $this->assertStringContainsString('Hyperion', $output); + $this->assertStringContainsString('Alice', $output); + $this->assertStringContainsString('Bob', $output); + $this->assertStringContainsString('Showing 2 purchase(s)', $output); + } + + #[Test] + public function product_argument_scopes_to_one_purchasable(): void + { + $other = Product::create([ + 'name' => 'Other Book', + 'sku' => 'OTH-LP', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + $this->purchase($this->alice, PurchaseStatus::COMPLETED); + ProductPurchase::create([ + 'purchasable_id' => $other->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => $this->bob->id, + 'purchaser_type' => User::class, + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::COMPLETED, + ]); + + Artisan::call(ShopListPurchasesCommand::class, ['product' => $this->book->id]); + $output = Artisan::output(); + + $this->assertStringContainsString('Hyperion', $output); + $this->assertStringNotContainsString('Other Book', $output); + $this->assertStringContainsString('Showing 1 purchase(s)', $output); + } + + #[Test] + public function purchaser_option_filters_by_buyer_id(): void + { + $this->purchase($this->alice, PurchaseStatus::COMPLETED); + $this->purchase($this->bob, PurchaseStatus::COMPLETED); + + Artisan::call(ShopListPurchasesCommand::class, ['--purchaser' => $this->bob->id]); + $output = Artisan::output(); + + $this->assertStringContainsString('Bob', $output); + $this->assertStringNotContainsString('Alice', $output); + $this->assertStringContainsString('Showing 1 purchase(s)', $output); + } + + #[Test] + public function status_option_filters_by_purchase_status(): void + { + $this->purchase($this->alice, PurchaseStatus::COMPLETED); + $this->purchase($this->bob, PurchaseStatus::PENDING); + + Artisan::call(ShopListPurchasesCommand::class, ['--status' => PurchaseStatus::PENDING->value]); + $output = Artisan::output(); + + $this->assertStringContainsString('Bob', $output); + $this->assertStringNotContainsString('Alice', $output); + } + + #[Test] + public function limit_option_caps_the_result_set(): void + { + // Three purchases — cap to 2. + $this->purchase($this->alice, PurchaseStatus::COMPLETED); + $this->purchase($this->bob, PurchaseStatus::COMPLETED); + $this->purchase(User::factory()->create(['name' => 'Cara']), PurchaseStatus::COMPLETED); + + Artisan::call(ShopListPurchasesCommand::class, ['--limit' => 2]); + $output = Artisan::output(); + + $this->assertStringContainsString('Showing 2 purchase(s)', $output); + } + + #[Test] + public function falls_back_to_id_when_purchasable_or_purchaser_was_deleted(): void + { + // Models can vanish (soft- or hard-deletes); the describe helpers must + // not crash and should render a truncated ID instead of a blank cell. + $purchase = $this->purchase($this->alice, PurchaseStatus::COMPLETED); + $this->book->delete(); + $this->alice->delete(); + + $exit = Artisan::call(ShopListPurchasesCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('ID:', $output, 'fallback "ID: …" labels render for missing models'); + } +} diff --git a/tests/Feature/CommandListTest.php b/tests/Feature/CommandListTest.php index 324674a..cf1368e 100644 --- a/tests/Feature/CommandListTest.php +++ b/tests/Feature/CommandListTest.php @@ -6,9 +6,12 @@ use Blax\Shop\Console\Commands\ShopListCartsCommand; use Blax\Shop\Console\Commands\ShopListCategoriesCommand; use Blax\Shop\Console\Commands\ShopListCommand; use Blax\Shop\Console\Commands\ShopListOrdersCommand; +use Blax\Shop\Enums\OrderStatus; use Blax\Shop\Enums\ProductStatus; use Blax\Shop\Enums\ProductType; use Blax\Shop\Models\Cart; +use Blax\Shop\Models\CartItem; +use Blax\Shop\Models\Order; use Blax\Shop\Models\Product; use Blax\Shop\Models\ProductCategory; use Blax\Shop\Tests\TestCase; @@ -83,4 +86,136 @@ class CommandListTest extends TestCase $this->assertStringContainsString('', $output); $this->assertStringContainsString('Showing 1 cart', $output); } + + /* ───────────────────── shop:list:categories gaps ───────────────────── */ + + #[Test] + public function shop_list_categories_reports_empty_state_when_no_rows(): void + { + $exit = Artisan::call(ShopListCategoriesCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No categories found.', $output); + } + + #[Test] + public function shop_list_categories_with_products_adds_a_count_column(): void + { + $fiction = ProductCategory::create(['name' => 'Fiction', 'slug' => 'fiction']); + ProductCategory::create(['name' => 'Empty', 'slug' => 'empty']); + + $p1 = Product::create(['name' => 'Book A', 'sku' => 'BA', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + $p2 = Product::create(['name' => 'Book B', 'sku' => 'BB', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + $fiction->products()->attach([$p1->id, $p2->id]); + + Artisan::call(ShopListCategoriesCommand::class, ['--with-products' => true]); + $output = Artisan::output(); + + $this->assertStringContainsString('Products', $output, 'column header is present'); + $this->assertMatchesRegularExpression('/Fiction.*?\b2\b/', $output); + $this->assertMatchesRegularExpression('/Empty.*?\b0\b/', $output); + } + + /* ───────────────────── shop:list:orders gaps ───────────────────────── */ + + #[Test] + public function shop_list_orders_renders_orders_with_status_and_currency(): void + { + Order::create([ + 'order_number' => 'ORD-001', + 'currency' => 'EUR', + 'status' => OrderStatus::PENDING, + 'amount_total' => 1500, + ]); + Order::create([ + 'order_number' => 'ORD-002', + 'currency' => 'EUR', + 'status' => OrderStatus::COMPLETED, + 'amount_total' => 9999, + ]); + + Artisan::call(ShopListOrdersCommand::class); + $output = Artisan::output(); + + $this->assertStringContainsString('ORD-001', $output); + $this->assertStringContainsString('ORD-002', $output); + $this->assertStringContainsString('Showing 2 order(s)', $output); + // 9999 cents → "99.99" rendered with thousand separators. + $this->assertStringContainsString('99.99', $output); + } + + #[Test] + public function shop_list_orders_status_filter_narrows_the_set(): void + { + Order::create(['order_number' => 'ORD-PENDING', 'currency' => 'EUR', 'status' => OrderStatus::PENDING, 'amount_total' => 100]); + Order::create(['order_number' => 'ORD-DONE', 'currency' => 'EUR', 'status' => OrderStatus::COMPLETED, 'amount_total' => 200]); + + Artisan::call(ShopListOrdersCommand::class, ['--status' => OrderStatus::COMPLETED->value]); + $output = Artisan::output(); + + $this->assertStringContainsString('ORD-DONE', $output); + $this->assertStringNotContainsString('ORD-PENDING', $output); + } + + #[Test] + public function shop_list_orders_customer_filter_narrows_to_one_buyer(): void + { + Order::create(['order_number' => 'ORD-MINE', 'currency' => 'EUR', 'customer_id' => 'buyer-1', 'customer_type' => 'App\\Models\\User', 'amount_total' => 0]); + Order::create(['order_number' => 'ORD-OTHER', 'currency' => 'EUR', 'customer_id' => 'buyer-2', 'customer_type' => 'App\\Models\\User', 'amount_total' => 0]); + + Artisan::call(ShopListOrdersCommand::class, ['--customer' => 'buyer-1']); + $output = Artisan::output(); + + $this->assertStringContainsString('ORD-MINE', $output); + $this->assertStringNotContainsString('ORD-OTHER', $output); + } + + /* ───────────────────── shop:list:carts gaps ────────────────────────── */ + + #[Test] + public function shop_list_carts_default_lists_every_cart(): void + { + Cart::create(['customer_type' => 'App\\Models\\User', 'customer_id' => 'u-1']); + Cart::create(['session_id' => 'guest-1']); + + Artisan::call(ShopListCartsCommand::class); + $output = Artisan::output(); + + $this->assertStringContainsString('', $output); + $this->assertStringContainsString('Showing', $output); + } + + #[Test] + public function shop_list_carts_reports_empty_state(): void + { + $exit = Artisan::call(ShopListCartsCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('No carts found.', $output); + } + + #[Test] + public function shop_list_carts_with_items_adds_a_count_column(): void + { + $cart = Cart::create(['session_id' => 'guest-with-items']); + $product = Product::create(['name' => 'Widget', 'sku' => 'W-1', 'type' => ProductType::SIMPLE, 'status' => ProductStatus::PUBLISHED, 'manage_stock' => false, 'is_visible' => true]); + CartItem::create([ + 'cart_id' => $cart->id, + 'purchasable_type' => Product::class, + 'purchasable_id' => $product->id, + 'quantity' => 2, + 'unit_price' => 100, + ]); + + Artisan::call(ShopListCartsCommand::class, ['--with-items' => true]); + $output = Artisan::output(); + + $this->assertStringContainsString('Items', $output, 'Items column header appears'); + // The session column is truncated to 12 chars (guest-with-i…) by the + // command, so we match the truncated prefix; the single cart has 1 + // item row, since withCount('items') tallies relations (not units). + $this->assertMatchesRegularExpression('/guest-with-i.*?\b1\b/', $output); + } } diff --git a/tests/Feature/CommandReinstallTest.php b/tests/Feature/CommandReinstallTest.php new file mode 100644 index 0000000..8222f53 --- /dev/null +++ b/tests/Feature/CommandReinstallTest.php @@ -0,0 +1,123 @@ + 'Doomed', + 'sku' => 'DOOMED-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + Cart::create(['session_id' => 'doomed-cart']); + + $exit = Artisan::call(ShopReinstallCommand::class, ['--force' => true]); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Starting shop reinstallation', $output); + $this->assertStringContainsString('Shop tables reinstalled successfully', $output); + + // Tables exist (migrations re-ran)… + $this->assertTrue(Schema::hasTable('product_purchases')); + $this->assertTrue(Schema::hasTable('product_stocks')); + $this->assertTrue(Schema::hasTable('carts')); + + // …but the seeded rows are gone. + $this->assertSame(0, Product::count()); + $this->assertSame(0, Cart::count()); + } + + #[Test] + public function declining_either_confirmation_short_circuits_with_a_cancelled_message(): void + { + // First confirmation says "no" → command exits without touching tables. + Product::create([ + 'name' => 'Survivor', + 'sku' => 'SURV-1', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + + $this->artisan(ShopReinstallCommand::class) + ->expectsConfirmation('Are you absolutely sure you want to continue?', 'no') + ->expectsOutputToContain('Operation cancelled.') + ->assertExitCode(0); + + $this->assertSame(1, Product::count(), 'data survives a cancelled reinstall'); + } + + #[Test] + public function declining_the_second_confirmation_also_short_circuits(): void + { + // First confirm says "yes", second says "no" — the second guard kicks + // in before any destructive work happens. + Product::create([ + 'name' => 'Also Survivor', + 'sku' => 'SURV-2', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + + $this->artisan(ShopReinstallCommand::class) + ->expectsConfirmation('Are you absolutely sure you want to continue?', 'yes') + ->expectsConfirmation('This action cannot be undone. Continue?', 'no') + ->expectsOutputToContain('Operation cancelled.') + ->assertExitCode(0); + + $this->assertSame(1, Product::count()); + } + + #[Test] + public function fresh_flag_is_synonymous_with_force_for_prompt_skipping(): void + { + // --fresh skips prompts just like --force; both are honoured by the + // same `!force && !fresh` guard. + Product::create([ + 'name' => 'Doomed Too', + 'sku' => 'DOOMED-2', + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ]); + + $exit = Artisan::call(ShopReinstallCommand::class, ['--fresh' => true]); + + $this->assertSame(0, $exit); + $this->assertSame(0, Product::count()); + } +} diff --git a/tests/Feature/CommandReleaseExpiredStocksTest.php b/tests/Feature/CommandReleaseExpiredStocksTest.php new file mode 100644 index 0000000..33496ea --- /dev/null +++ b/tests/Feature/CommandReleaseExpiredStocksTest.php @@ -0,0 +1,130 @@ + 'Reservable', + 'sku' => 'RES-'.uniqid(), + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => true, + ]); + } + + #[Test] + public function returns_zero_count_when_nothing_has_expired(): void + { + $product = $this->product(); + $product->increaseStock(5); + // An active, unexpired claim that should NOT be released. + $product->claimStock( + 1, + null, + Carbon::now()->subMinutes(5), + Carbon::now()->addHours(2), + 'active claim', + ); + + $exit = Artisan::call(ReleaseExpiredStocks::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Released 0 expired stock claim(s).', $output); + } + + #[Test] + public function releases_only_expired_claims_and_reports_the_count(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $product = $this->product(); + $product->increaseStock(3); + + // Expired claim — claim window ended an hour ago. + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 08:00:00'), + Carbon::parse('2026-05-14 09:00:00'), + 'expired claim', + ); + // Active claim — still open. + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 09:30:00'), + Carbon::parse('2026-05-14 18:00:00'), + 'active claim', + ); + + $exit = Artisan::call(ReleaseExpiredStocks::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Released 1 expired stock claim(s).', $output); + + // The expired claim flipped to COMPLETED status (released), the active + // one still has PENDING. + $pendingClaims = $product->stocks() + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value) + ->get(); + $this->assertCount(1, $pendingClaims); + $this->assertSame('active claim', $pendingClaims->first()->note); + } + + #[Test] + public function short_circuits_when_auto_release_is_disabled_in_config(): void + { + config(['shop.stock.auto_release_expired' => false]); + + $product = $this->product(); + $product->increaseStock(2); + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 08:00:00'), + Carbon::parse('2026-05-14 09:00:00'), + 'would-be-released', + ); + + $exit = Artisan::call(ReleaseExpiredStocks::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('Auto-release is disabled in config.', $output); + + // The claim is still PENDING since the command bailed. + $stillPending = $product->stocks() + ->where('type', StockType::CLAIMED->value) + ->where('status', StockStatus::PENDING->value) + ->count(); + $this->assertSame(1, $stillPending); + } +} diff --git a/tests/Feature/CommandStatsTest.php b/tests/Feature/CommandStatsTest.php new file mode 100644 index 0000000..a50c8b9 --- /dev/null +++ b/tests/Feature/CommandStatsTest.php @@ -0,0 +1,159 @@ + 'Stat Product '.uniqid(), + 'sku' => 'STAT-'.uniqid(), + 'type' => ProductType::SIMPLE, + 'status' => ProductStatus::PUBLISHED, + 'is_visible' => true, + 'manage_stock' => false, + ], $overrides)); + } + + #[Test] + public function shop_stats_runs_against_an_empty_database_with_zeroes(): void + { + $exit = Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + $this->assertSame(0, $exit); + $this->assertStringContainsString('=== Shop Statistics ===', $output); + $this->assertStringContainsString('Products: total', $output); + $this->assertStringContainsString('Purchases: total', $output); + $this->assertStringContainsString('Revenue (paid)', $output); + } + + #[Test] + public function shop_stats_breaks_products_down_by_status_and_visibility(): void + { + $this->newProduct(['status' => ProductStatus::PUBLISHED, 'is_visible' => true]); + $this->newProduct(['status' => ProductStatus::PUBLISHED, 'is_visible' => false]); + $this->newProduct(['status' => ProductStatus::DRAFT, 'is_visible' => true]); + + Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + // 3 total, 2 published, 2 visible — the command renders these as + // standard table rows; we check the values appear adjacent to their + // labels rather than as raw numbers (which could collide with other + // row totals). + $this->assertMatchesRegularExpression('/Products: total\s*\|\s*3\b/', $output); + $this->assertMatchesRegularExpression('/Products: published\s*\|\s*2\b/', $output); + $this->assertMatchesRegularExpression('/Products: visible\s*\|\s*2\b/', $output); + } + + #[Test] + public function shop_stats_breaks_actions_down_by_active_flag(): void + { + $product = $this->newProduct(); + ProductAction::create([ + 'product_id' => $product->id, + 'events' => ['purchased'], + 'class' => 'App\\Foo', + 'active' => true, + ]); + ProductAction::create([ + 'product_id' => $product->id, + 'events' => ['purchased'], + 'class' => 'App\\Bar', + 'active' => false, + ]); + + Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/Actions: total\s*\|\s*2\b/', $output); + $this->assertMatchesRegularExpression('/Actions: active\s*\|\s*1\b/', $output); + $this->assertMatchesRegularExpression('/Actions: inactive\s*\|\s*1\b/', $output); + } + + #[Test] + public function shop_stats_groups_purchases_by_status_and_sums_revenue(): void + { + $product = $this->newProduct(); + + $userType = 'App\\Models\\User'; + ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'u1', + 'purchaser_type' => $userType, + 'quantity' => 1, + 'amount' => 2500, + 'amount_paid' => 2500, + 'status' => PurchaseStatus::COMPLETED, + ]); + ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'u2', + 'purchaser_type' => $userType, + 'quantity' => 1, + 'amount' => 1500, + 'amount_paid' => 1500, + 'status' => PurchaseStatus::COMPLETED, + ]); + ProductPurchase::create([ + 'purchasable_id' => $product->id, + 'purchasable_type' => Product::class, + 'purchaser_id' => 'u3', + 'purchaser_type' => $userType, + 'quantity' => 1, + 'amount' => 1000, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + ]); + + Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/Purchases: total\s*\|\s*3\b/', $output); + $this->assertMatchesRegularExpression('/Purchases: completed\s*\|\s*2\b/', $output); + $this->assertMatchesRegularExpression('/Purchases: pending\s*\|\s*1\b/', $output); + + // Revenue = sum(amount_paid) / 100 → 4000 cents = 40.00. + $this->assertMatchesRegularExpression('/Revenue \(paid\)\s*\|\s*40\.00\b/', $output); + } + + #[Test] + public function shop_stats_includes_carts_and_orders_when_models_are_configured(): void + { + Cart::create(['session_id' => 'sess-stats-1']); + Cart::create(['session_id' => 'sess-stats-2']); + Order::create(['currency' => 'EUR']); + + Artisan::call(ShopStatsCommand::class); + $output = Artisan::output(); + + $this->assertMatchesRegularExpression('/Carts: total\s*\|\s*2\b/', $output); + $this->assertMatchesRegularExpression('/Orders: total\s*\|\s*1\b/', $output); + } +} diff --git a/tests/Feature/Loan/LoanShopCommandsTest.php b/tests/Feature/Loan/LoanShopCommandsTest.php new file mode 100644 index 0000000..dbefbdc --- /dev/null +++ b/tests/Feature/Loan/LoanShopCommandsTest.php @@ -0,0 +1,315 @@ +borrower = User::factory()->create(); + } + + private function newBook(string $name, string $sku): CmdLoanBook + { + return CmdLoanBook::create(['name' => $name, 'sku' => $sku]); + } + + private function runOk(string $command, array $params = []): string + { + $exit = Artisan::call($command, $params); + $output = Artisan::output(); + $this->assertSame(0, $exit, "{$command} returned non-zero:\n{$output}"); + + return $output; + } + + /* ─────────────────────── shop:stocks (overview) ─────────────────────── */ + + #[Test] + public function shop_stocks_overview_lists_a_loaned_book_with_correct_counts(): void + { + $book = $this->newBook('Dune', 'CMD-DUNE'); + $book->increaseStock(3); + $book->checkOutTo($this->borrower); + + $output = $this->runOk(ShopStocksCommand::class); + + $this->assertStringContainsString('Dune', $output); + + $this->assertSame(1, $this->loanedDecreases($book), 'one DECREASE row from the loan'); + $this->assertSame(2, $book->fresh()->getAvailableStock(), '3 copies − 1 loan = 2 available'); + } + + #[Test] + public function shop_stocks_overview_for_a_fully_loaned_book_reports_zero_available(): void + { + $book = $this->newBook('Ember', 'CMD-EMBER'); + $book->increaseStock(1); + $book->checkOutTo($this->borrower); + + $output = $this->runOk(ShopStocksCommand::class); + + $this->assertStringContainsString('Ember', $output); + $this->assertSame(0, $book->fresh()->getAvailableStock(), 'last copy is out'); + $this->assertSame(1, $this->loanedDecreases($book->fresh())); + } + + /* ─────────────────────── shop:stocks (detail) ─────────────────────── */ + + #[Test] + public function shop_stocks_detail_view_renders_the_full_ledger_for_a_loaned_book(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $book = $this->newBook('Hyperion', 'CMD-HYP'); + $book->increaseStock(3); + + // Borrow twice, return one. Ledger = seed + 2 decreases + 1 increase. + $loan = $book->checkOutTo($this->borrower); + $book->checkOutTo(User::factory()->create()); + $loan->markReturned(); + $book->increaseStock(1); + + $output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP']); + + $this->assertStringContainsString('Hyperion', $output); + $this->assertStringContainsString('ASSIGNED', $output); + $this->assertStringContainsString('AVAILABLE', $output); + $this->assertStringContainsString('Recent stock ledger', $output); + $this->assertStringContainsString('increase', $output); + $this->assertStringContainsString('decrease', $output); + + // 3 copies, 1 still out → 2 available. Verified against the model to + // sidestep ASCII-column regex fragility. + $this->assertSame(2, $book->fresh()->getAvailableStock()); + } + + #[Test] + public function assigned_for_loanable_product_must_not_inflate_after_a_return_cycle(): void + { + // Regression test for the bug where shop:stocks rendered Assigned=4 + // for a 3-copy book that had been borrowed-and-returned once. The + // sequence is: INCREASE +3 (seed), DECREASE -1 (loan), INCREASE +1 + // (host-driven return restock). getMaxStocksAttribute sums every + // positive entry — so it returned 3 + 1 = 4 — which the command then + // rendered verbatim. Fix: consult `total_quantity`, which is loan-aware + // (available stock + active loans = physical inventory we own). + $book = $this->newBook('Hyperion', 'CMD-HYP-RC'); + $book->increaseStock(3); + + $loan = $book->checkOutTo($this->borrower); + $loan->markReturned(); + $book->increaseStock(1); + + // Sanity: model accessors disagree — getMaxStocksAttribute is inflated + // (this is the documented limitation of the underlying calc), while + // total_quantity reports the truth. The command must use total_quantity. + $fresh = $book->fresh(); + $this->assertSame(4, $fresh->getMaxStocksAttribute(), 'inflated by design'); + $this->assertSame(3, (int) $fresh->total_quantity, 'loan-aware accessor'); + + // Detail view: ASSIGNED row must be 3, not 4. + $output = $this->runOk(ShopStocksCommand::class, ['product' => 'CMD-HYP-RC']); + + // The detail view renders labels on one row and values on the next. + // Split into lines and look at the value row that follows the ASSIGNED + // label row — the first numeric token in that line is Assigned. + $assigned = $this->detailViewAssignedValue($output); + $this->assertSame( + 3, + $assigned, + "shop:stocks detail must render Assigned=3 (physical capacity), got {$assigned}", + ); + + // Overview must also be consistent. + $overviewOutput = $this->runOk(ShopStocksCommand::class); + $row = $this->overviewRow($overviewOutput, 'Hyperion'); + $this->assertNotNull($row, 'Hyperion row must appear in the overview table'); + $this->assertSame( + 3, + $row['assigned'], + 'shop:stocks overview must report Assigned=3 for a borrowed-then-returned 3-copy book', + ); + } + + /* ─────────────────── shop:stocks:availability (calendar) ─────────────── */ + + #[Test] + public function shop_stocks_availability_headline_reads_zero_for_a_fully_loaned_book(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $book = $this->newBook('Solitaire', 'CMD-SOL'); + $book->increaseStock(1); + $book->checkOutTo($this->borrower); + + $output = $this->runOk(ShopAvailabilityCommand::class, [ + 'product' => 'CMD-SOL', + '--from' => '2026-05-01', + '--to' => '2026-05-31', + ]); + + $this->assertStringContainsString('Solitaire', $output); + $this->assertStringContainsString('May 2026', $output); + $this->assertStringContainsString('Available 0', $output); + $this->assertStringContainsString('MON', $output); + $this->assertStringContainsString('SUN', $output); + } + + #[Test] + public function shop_stocks_availability_headline_recovers_after_a_return(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + + $book = $this->newBook('Singular', 'CMD-SIN'); + $book->increaseStock(1); + $loan = $book->checkOutTo($this->borrower); + $loan->markReturned(); + $book->increaseStock(1); + + $output = $this->runOk(ShopAvailabilityCommand::class, [ + 'product' => 'CMD-SIN', + '--from' => '2026-05-01', + '--to' => '2026-05-31', + ]); + + $this->assertStringContainsString('Available 1', $output); + $this->assertSame(1, $book->fresh()->getAvailableStock()); + } + + #[Test] + public function shop_stocks_availability_day_view_for_unmanaged_book_shows_unlimited(): void + { + // manage_stock=false on a loanable book is the moonshiner "infinite + // copy" pattern: every borrower can take a copy at any time. + $book = $this->newBook('Compendium', 'CMD-CMP'); + $book->manage_stock = false; + $book->save(); + + $output = $this->runOk(ShopAvailabilityCommand::class, [ + 'product' => 'CMD-CMP', + '--day' => '2026-05-14', + ]); + + $this->assertStringContainsString('Unlimited availability all day.', $output); + } + + /* ───────────────────────────── helpers ─────────────────────────────── */ + + /** + * Count of loan-driven DECREASE rows on this book — mirrors what the + * "Used" column in shop:stocks renders. + */ + private function loanedDecreases(CmdLoanBook $book): int + { + return (int) abs((int) $book->stocks() + ->withoutGlobalScope('willExpire') + ->where('type', \Blax\Shop\Enums\StockType::DECREASE->value) + ->where('status', \Blax\Shop\Enums\StockStatus::COMPLETED->value) + ->sum('quantity')); + } + + /** + * Extract the first numeric token from the value row that follows the + * ASSIGNED label row in the shop:stocks detail output. The detail view + * renders a single boxed row, so the first integer after "ASSIGNED" on + * the next non-empty line is the Assigned value. + */ + private function detailViewAssignedValue(string $output): ?int + { + $lines = explode("\n", $output); + $assignedLineIndex = null; + foreach ($lines as $i => $line) { + if (str_contains($line, 'ASSIGNED')) { + $assignedLineIndex = $i; + break; + } + } + if ($assignedLineIndex === null) { + return null; + } + + // Walk forward to the next line that contains digits. + for ($i = $assignedLineIndex + 1; $i < count($lines); $i++) { + if (preg_match('/\b(\d+)\b/', $lines[$i], $m)) { + return (int) $m[1]; + } + } + + return null; + } + + /** + * Pull the named row out of the shop:stocks overview table and parse the + * 4 trailing integer columns (Assigned / Used / Available / Claimed). + * + * @return array{assigned: int, used: int, available: int, claimed: int}|null + */ + private function overviewRow(string $output, string $needle): ?array + { + foreach (explode("\n", $output) as $line) { + if (! str_contains($line, $needle)) { + continue; + } + + // Capture the four numeric columns at the tail of the row. Type + // and ID columns precede them; we anchor on the trailing + // "n | n | n | n" shape (the only column run that's all integers). + // The overview renders via $this->table(...) which uses plain ASCII + // pipes — not the box-drawing │ used by the detail/availability + // commands. + if (preg_match('/(\d+)\s*\|\s*(\d+|—)\s*\|\s*(\d+|∞)\s*\|\s*(\d+)\s*\|\s*$/u', $line, $m)) { + return [ + 'assigned' => (int) $m[1], + 'used' => $m[2] === '—' ? 0 : (int) $m[2], + 'available' => $m[3] === '∞' ? PHP_INT_MAX : (int) $m[3], + 'claimed' => (int) $m[4], + ]; + } + } + + return null; + } +} + +/** + * Plug-n-pray loanable fixture, mirroring CheckOutToTest's LoanableBook but + * under a unique name so the two files can coexist in the same namespace. + */ +class CmdLoanBook extends Product +{ + public const DEFAULT_TYPE = ProductType::LOANABLE; + + protected $guarded = []; +} diff --git a/tests/Feature/Loan/LoanStockEventsTest.php b/tests/Feature/Loan/LoanStockEventsTest.php new file mode 100644 index 0000000..f1ae8e3 --- /dev/null +++ b/tests/Feature/Loan/LoanStockEventsTest.php @@ -0,0 +1,166 @@ +borrower = User::factory()->create(); + $this->book = EventLoanBook::create(['name' => 'Hyperion', 'sku' => 'HYP-EV-1']); + $this->book->increaseStock(3); + } + + #[Test] + public function checkOutTo_dispatches_stock_decreased_with_correct_payload(): void + { + Event::fake([StockDecreased::class]); + + $this->book->checkOutTo($this->borrower); + + Event::assertDispatched( + StockDecreased::class, + fn (StockDecreased $e) => $e->product->is($this->book) + && $e->availableAfter === 2 + && $e->entry instanceof ProductStock + && (int) $e->entry->quantity === -1 + && $e->entry->type === StockType::DECREASE + && $e->entry->status === StockStatus::COMPLETED, + ); + } + + #[Test] + public function checking_out_the_last_copy_dispatches_stock_depleted(): void + { + // 3 copies on the shelf — borrow all three; the third call crosses the + // last-copy boundary so StockDepleted must fire alongside StockDecreased. + $this->book->checkOutTo(User::factory()->create()); + $this->book->checkOutTo(User::factory()->create()); + + Event::fake([StockDepleted::class, StockDecreased::class]); + + $this->book->checkOutTo($this->borrower); + + Event::assertDispatched(StockDecreased::class); + Event::assertDispatched( + StockDepleted::class, + fn (StockDepleted $e) => $e->product->is($this->book), + ); + } + + #[Test] + public function partial_checkout_does_not_dispatch_stock_depleted(): void + { + Event::fake([StockDepleted::class]); + + $this->book->checkOutTo($this->borrower); + + Event::assertNotDispatched(StockDepleted::class); + } + + #[Test] + public function restocking_after_a_full_loan_dispatches_stock_replenished(): void + { + // Single-copy book, borrow it (depletes to 0), then a host-driven + // increaseStock(1) on the return path must cross 0→>0 and fire + // StockReplenished. Mirrors what moonshiner-library does in + // LoanController::returnLoan after $loan->markReturned(). + $single = EventLoanBook::create(['name' => 'Solitaire', 'sku' => 'SOL-EV-1']); + $single->increaseStock(1); + $loan = $single->checkOutTo($this->borrower); + $loan->markReturned(); + + $this->assertSame(0, $single->fresh()->getAvailableStock()); + + Event::fake([StockReplenished::class, StockIncreased::class]); + + $single->increaseStock(1); + + Event::assertDispatched(StockIncreased::class); + Event::assertDispatched( + StockReplenished::class, + fn (StockReplenished $e) => $e->product->is($single) && $e->availableAfter === 1, + ); + } + + #[Test] + public function restocking_when_other_copies_are_free_does_not_dispatch_replenished(): void + { + // 3-copy book, borrow 1 → 2 available. Returning that copy goes 2→3, + // NOT a 0→>0 transition, so StockReplenished must stay silent. + $loan = $this->book->checkOutTo($this->borrower); + $loan->markReturned(); + + Event::fake([StockReplenished::class]); + + $this->book->increaseStock(1); + + Event::assertNotDispatched(StockReplenished::class); + } + + #[Test] + public function event_wiring_holds_across_a_full_borrow_return_cycle(): void + { + // Full sequence: borrow → return-restock. We assert the relative count + // and payload of each event in one go so a future refactor that splits + // the path can't pass the per-step tests while breaking the rollup. + Event::fake([ + StockDecreased::class, + StockIncreased::class, + StockDepleted::class, + StockReplenished::class, + ]); + + $loan = $this->book->checkOutTo($this->borrower); + $loan->markReturned(); + $this->book->increaseStock(1); + + Event::assertDispatchedTimes(StockDecreased::class, 1); + Event::assertDispatchedTimes(StockIncreased::class, 1); + Event::assertNotDispatched(StockDepleted::class, '3→2 is not a depletion'); + Event::assertNotDispatched(StockReplenished::class, '2→3 is not a replenishment'); + } +} + +/** + * Same plug-n-pray fixture as CheckOutToTest's: declare DEFAULT_TYPE so the + * MayBeLoanableProduct creating-hook flips the row into loan mode. + */ +class EventLoanBook extends Product +{ + public const DEFAULT_TYPE = ProductType::LOANABLE; + + protected $guarded = []; +} diff --git a/tests/Feature/NextAvailableAtTest.php b/tests/Feature/NextAvailableAtTest.php new file mode 100644 index 0000000..ae97856 --- /dev/null +++ b/tests/Feature/NextAvailableAtTest.php @@ -0,0 +1,171 @@ + 'Test Book', + 'sku' => 'NEXT-'.uniqid(), + ]); + $product->increaseStock($copies); + + return $product; + } + + #[Test] + public function returns_null_when_stock_is_currently_available(): void + { + $product = $this->loanable(copies: 2); + $this->assertNull($product->nextAvailableAt()); + } + + #[Test] + public function returns_the_earliest_loan_until_when_all_copies_are_loaned_out(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product = $this->loanable(copies: 2); + + $borrowerOne = User::factory()->create(); + $borrowerTwo = User::factory()->create(); + + // Two loans, due at different times. Earliest is 2026-05-28. + $product->purchases()->create([ + 'purchaser_id' => $borrowerOne->getKey(), + 'purchaser_type' => User::class, + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + 'from' => Carbon::parse('2026-05-14 10:00:00'), + 'until' => Carbon::parse('2026-05-28 10:00:00'), + 'meta' => ['extensions_used' => 0], + ]); + $product->purchases()->create([ + 'purchaser_id' => $borrowerTwo->getKey(), + 'purchaser_type' => User::class, + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + 'from' => Carbon::parse('2026-05-14 10:00:00'), + 'until' => Carbon::parse('2026-06-01 10:00:00'), + 'meta' => ['extensions_used' => 0], + ]); + $product->decreaseStock(2); // simulate the loans taking stock + + $next = $product->nextAvailableAt(); + + $this->assertNotNull($next); + $this->assertSame( + Carbon::parse('2026-05-28 10:00:00')->toIso8601String(), + $next->toIso8601String(), + ); + } + + #[Test] + public function returns_the_earliest_pending_claim_expiry_when_stock_is_fully_claimed(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product = $this->loanable(copies: 1); + + // One claim that ends earlier than the loan-style flow would + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 10:00:00'), + Carbon::parse('2026-05-20 10:00:00'), + 'pending claim' + ); + + $next = $product->nextAvailableAt(); + + $this->assertNotNull($next); + $this->assertSame( + Carbon::parse('2026-05-20 10:00:00')->toIso8601String(), + $next->toIso8601String(), + ); + } + + #[Test] + public function returns_the_minimum_of_loan_end_and_claim_end_when_both_present(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product = $this->loanable(copies: 2); + + // Loan ends 2026-06-01 (later) + $product->purchases()->create([ + 'purchaser_id' => User::factory()->create()->getKey(), + 'purchaser_type' => User::class, + 'quantity' => 1, + 'amount' => 0, + 'amount_paid' => 0, + 'status' => PurchaseStatus::PENDING, + 'from' => Carbon::parse('2026-05-14 10:00:00'), + 'until' => Carbon::parse('2026-06-01 10:00:00'), + 'meta' => ['extensions_used' => 0], + ]); + $product->decreaseStock(1); + + // Claim ends 2026-05-18 (earlier — this should win) + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-14 10:00:00'), + Carbon::parse('2026-05-18 10:00:00'), + 'short claim' + ); + + $next = $product->nextAvailableAt(); + + $this->assertNotNull($next); + $this->assertSame( + Carbon::parse('2026-05-18 10:00:00')->toIso8601String(), + $next->toIso8601String(), + ); + } + + #[Test] + public function ignores_already_expired_claims(): void + { + Carbon::setTestNow(Carbon::parse('2026-05-14 10:00:00')); + $product = $this->loanable(copies: 1); + + // Claim is already past expiry — should not be considered. + $product->claimStock( + 1, + null, + Carbon::parse('2026-05-01 10:00:00'), + Carbon::parse('2026-05-10 10:00:00'), + 'expired claim' + ); + + $next = $product->nextAvailableAt(); + + // Stock was freed by the expired claim's RETURN row? No — claim isn't + // released yet (releaseExpired() wasn't run). But the expires_at is + // already in the past, so nextAvailableAt should return null + // (nothing actively reserving stock counts as a freeing-up signal). + $this->assertNull($next); + } +} + +class LoanableNextAvailableBook extends Product +{ + public const DEFAULT_TYPE = ProductType::LOANABLE; + protected $guarded = []; +}