BF commands, A command tests, I traits

- Implement CommandReinstallTest to verify the behavior of the shop:reinstall command, including force and fresh flags, and confirmation prompts.
- Create CommandReleaseExpiredStocksTest to test the shop:release-expired-stocks command, ensuring it correctly releases expired stock claims based on configuration.
- Add CommandStatsTest to validate the shop:stats command, checking counts and revenue calculations for products, purchases, carts, and orders.
- Introduce LoanShopCommandsTest to cover loanable products in stock management, ensuring accurate reporting of assigned, used, and available stock.
- Implement LoanStockEventsTest to verify that stock events are dispatched correctly during loan operations, including checkouts and returns.
- Add NextAvailableAtTest to ensure the nextAvailableAt method behaves correctly for loanable products, considering loans and claims.
This commit is contained in:
Fabian @ Blax Software 2026-05-17 13:25:34 +02:00
parent ee64e86345
commit 99fd71f4ae
15 changed files with 2011 additions and 12 deletions

View File

@ -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<array{0: string, 1: int, 2: string}> $boxes
*/

View File

@ -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')) {
// 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');
$action_job = $namespace . '\\' . $action->action_class;
$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.');
}

View File

@ -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;
}

View File

@ -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
*

View File

@ -0,0 +1,238 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopTestActionCommand;
use Blax\Shop\Console\Commands\ShopToggleActionCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\ProductActionRun;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
/**
* Coverage for the two ProductAction CLI helpers:
*
* - shop:toggle-action flips the `active` flag (or honours --enable/--disable).
* - shop:test-action fires the action against its product, optionally synchronously.
*
* Until now neither command was tested. Adding coverage surfaced two
* column-name bugs:
*
* 1. ShopToggleActionCommand wrote to a non-existent `enabled` column
* instead of `active` toggle was a silent no-op (or a SQL error in
* strict mode), and the rendered "$action->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++;
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopCleanupCartsCommand;
use Blax\Shop\Enums\CartStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
/**
* shop:cleanup-carts has a 2-stage state machine:
* - expire ACTIVE carts past `shop.cart.expiration_minutes` of inactivity
* flip to EXPIRED status (still persisted).
* - delete carts past `shop.cart.deletion_hours` of inactivity are removed
* outright (CONVERTED carts are excluded).
*
* Flags --expire and --delete narrow to one stage; --dry-run reports without
* mutating; --force skips the deletion confirmation prompt.
*/
class CommandCleanupCartsTest extends TestCase
{
use RefreshDatabase;
/** A stale ACTIVE cart with last_activity_at well past expiration. */
private function staleActiveCart(): Cart
{
$cart = Cart::create([
'session_id' => '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);
}
}

View File

@ -0,0 +1,134 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopListProductsCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
/**
* shop:list:products is the operator's catalogue browser every filter
* combination should narrow the set predictably and the optional --with-X
* flags should add their respective columns.
*/
class CommandListProductsTest extends TestCase
{
use RefreshDatabase;
private function newProduct(array $overrides = []): Product
{
return Product::create(array_merge([
'name' => '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);
}
}

View File

@ -0,0 +1,173 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopListPurchasesCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* shop:list:purchases is the cross-cutting view of every consumption event
* (loan, booking, sale). The command supports three filters (product,
* purchaser, status) plus --limit; this file exercises each one and the
* empty-state branch.
*/
class CommandListPurchasesTest extends TestCase
{
use RefreshDatabase;
private Product $book;
private User $alice;
private User $bob;
protected function setUp(): void
{
parent::setUp();
$this->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');
}
}

View File

@ -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('<guest>', $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('<guest>', $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);
}
}

View File

@ -0,0 +1,123 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopReinstallCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schema;
use PHPUnit\Framework\Attributes\Test;
/**
* shop:reinstall is the package's escape hatch drop every shop table and
* re-run the migrations from scratch. It is destructive by design and guarded
* by two confirmation prompts unless --force or --fresh is passed.
*
* Coverage targets the --force happy path (no prompts) and the cancellation
* branches; we don't poke at the migrations themselves (other tests cover
* that) only the command's orchestration.
*/
class CommandReinstallTest extends TestCase
{
use RefreshDatabase;
#[Test]
public function force_flag_skips_prompts_and_recreates_the_schema(): void
{
// Seed some data so we can assert it's gone after the reinstall.
Product::create([
'name' => '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());
}
}

View File

@ -0,0 +1,130 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ReleaseExpiredStocks;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
/**
* shop:release-expired-stocks delegates to ProductStock::releaseExpired().
* The command should: report the released count, honour the
* shop.stock.auto_release_expired config flag, and be a no-op when nothing
* has actually expired.
*/
class CommandReleaseExpiredStocksTest extends TestCase
{
use RefreshDatabase;
private function product(): Product
{
return Product::create([
'name' => '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);
}
}

View File

@ -0,0 +1,159 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Console\Commands\ShopStatsCommand;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Models\Cart;
use Blax\Shop\Models\Order;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductAction;
use Blax\Shop\Models\ProductPurchase;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
/**
* shop:stats renders an at-a-glance dashboard of every domain object in the
* package (products, actions, purchases, carts, orders). The command reads
* counts straight off each model, so a future schema or scope change shows up
* here as wrong totals earlier than any HTTP/UI integration would catch it.
*/
class CommandStatsTest extends TestCase
{
use RefreshDatabase;
private function newProduct(array $overrides = []): Product
{
return Product::create(array_merge([
'name' => '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);
}
}

View File

@ -0,0 +1,315 @@
<?php
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Console\Commands\ShopAvailabilityCommand;
use Blax\Shop\Console\Commands\ShopStocksCommand;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Inspector commands ({@see ShopStocksCommand}, {@see ShopAvailabilityCommand})
* read from product_stocks / product_purchases. CommandStocksTest and
* CommandAvailabilityTest already exercise them against SIMPLE products; this
* file covers the LOANABLE path borrow + return cycles. A regression in
* loan↔stock wiring shows up here as wrong Assigned / Used / Available numbers
* earlier than any HTTP test would catch it.
*
* The Assigned-inflation bug ({@see self::assigned_for_loanable_product_must_not_inflate_after_a_return_cycle})
* is enforced here: getMaxStocksAttribute() sums every INCREASE entry and
* the host-driven return path fires an INCREASE so a 3-copy book that's
* been borrowed-and-returned once used to render as Assigned=4. The command
* now consults the loan-aware `total_quantity` accessor instead.
*/
class LoanShopCommandsTest extends TestCase
{
use RefreshDatabase;
private User $borrower;
protected function setUp(): void
{
parent::setUp();
$this->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 = [];
}

View File

@ -0,0 +1,166 @@
<?php
namespace Blax\Shop\Tests\Feature\Loan;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\StockStatus;
use Blax\Shop\Enums\StockType;
use Blax\Shop\Events\StockDecreased;
use Blax\Shop\Events\StockDepleted;
use Blax\Shop\Events\StockIncreased;
use Blax\Shop\Events\StockReplenished;
use Blax\Shop\Models\Product;
use Blax\Shop\Models\ProductStock;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
/**
* Loans go through HasStocks::decreaseStock() and (host-driven) increaseStock()
* for return, so they automatically participate in the StockDecreased /
* StockIncreased / StockDepleted / StockReplenished event chain. EventsWiredUpTest
* proves those events fire for direct decrease/increase calls; this file
* pins down that the LOAN-driven paths (checkOutTo, markReturned-then-restock)
* benefit from the same wiring, so external listeners (low-stock alerts,
* search reindex, librarian notifications) react identically regardless of
* whether stock moved via a checkout or a loan.
*/
class LoanStockEventsTest extends TestCase
{
use RefreshDatabase;
private User $borrower;
private EventLoanBook $book;
protected function setUp(): void
{
parent::setUp();
$this->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 = [];
}

View File

@ -0,0 +1,171 @@
<?php
namespace Blax\Shop\Tests\Feature;
use Blax\Shop\Enums\ProductStatus;
use Blax\Shop\Enums\ProductType;
use Blax\Shop\Enums\PurchaseStatus;
use Blax\Shop\Models\Product;
use Blax\Shop\Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use PHPUnit\Framework\Attributes\Test;
use Workbench\App\Models\User;
class NextAvailableAtTest extends TestCase
{
use RefreshDatabase;
private function loanable(int $copies = 1): Product
{
$product = LoanableNextAvailableBook::create([
'name' => '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 = [];
}