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:
parent
ee64e86345
commit
99fd71f4ae
|
|
@ -37,7 +37,7 @@ class ShopStocksCommand extends Command
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = $products->map(function (Product $product): array {
|
$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;
|
$used = $product->manage_stock ? $this->totalUsed($product) : null;
|
||||||
$available = $product->getAvailableStock();
|
$available = $product->getAvailableStock();
|
||||||
$claimed = $product->getCurrentlyClaimedStock();
|
$claimed = $product->getCurrentlyClaimedStock();
|
||||||
|
|
@ -88,7 +88,7 @@ class ShopStocksCommand extends Command
|
||||||
return self::SUCCESS;
|
return self::SUCCESS;
|
||||||
}
|
}
|
||||||
|
|
||||||
$assigned = (int) $product->getMaxStocksAttribute();
|
$assigned = $this->assignedCapacity($product);
|
||||||
$used = $this->totalUsed($product);
|
$used = $this->totalUsed($product);
|
||||||
$available = $product->getAvailableStock();
|
$available = $product->getAvailableStock();
|
||||||
$currentClaims = $product->getCurrentlyClaimedStock();
|
$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
|
* @param list<array{0: string, 1: int, 2: string}> $boxes
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ class ShopTestActionCommand extends Command
|
||||||
return 1;
|
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("Product: {$action->product->name} (ID: {$action->product_id})");
|
||||||
$this->info("Event: {$action->event}");
|
$this->info("Event: {$action->event}");
|
||||||
|
|
||||||
|
|
@ -36,8 +36,14 @@ class ShopTestActionCommand extends Command
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($this->option('sync')) {
|
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');
|
$namespace = config('shop.actions.namespace', 'App\\Jobs\\ProductAction');
|
||||||
$action_job = $namespace . '\\' . $action->action_class;
|
$actionClass = $namespace . '\\' . $actionClass;
|
||||||
|
}
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
'product' => $action->product,
|
'product' => $action->product,
|
||||||
|
|
@ -46,10 +52,22 @@ class ShopTestActionCommand extends Command
|
||||||
...($action->parameters ?? []),
|
...($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.');
|
$this->info('Action executed synchronously.');
|
||||||
} else {
|
} 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.');
|
$this->info('Action dispatched to queue.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,20 +26,25 @@ class ShopToggleActionCommand extends Command
|
||||||
return 1;
|
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')) {
|
if ($this->option('enable')) {
|
||||||
$action->enabled = true;
|
$action->active = true;
|
||||||
$status = 'enabled';
|
$status = 'enabled';
|
||||||
} elseif ($this->option('disable')) {
|
} elseif ($this->option('disable')) {
|
||||||
$action->enabled = false;
|
$action->active = false;
|
||||||
$status = 'disabled';
|
$status = 'disabled';
|
||||||
} else {
|
} else {
|
||||||
$action->enabled = !$action->enabled;
|
$action->active = ! $action->active;
|
||||||
$status = $action->enabled ? 'enabled' : 'disabled';
|
$status = $action->active ? 'enabled' : 'disabled';
|
||||||
}
|
}
|
||||||
|
|
||||||
$action->save();
|
$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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -575,6 +575,48 @@ trait HasStocks
|
||||||
return $this->getAvailableStock() <= $this->low_stock_threshold;
|
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
|
* Get active claims for this product
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,12 @@ use Blax\Shop\Console\Commands\ShopListCartsCommand;
|
||||||
use Blax\Shop\Console\Commands\ShopListCategoriesCommand;
|
use Blax\Shop\Console\Commands\ShopListCategoriesCommand;
|
||||||
use Blax\Shop\Console\Commands\ShopListCommand;
|
use Blax\Shop\Console\Commands\ShopListCommand;
|
||||||
use Blax\Shop\Console\Commands\ShopListOrdersCommand;
|
use Blax\Shop\Console\Commands\ShopListOrdersCommand;
|
||||||
|
use Blax\Shop\Enums\OrderStatus;
|
||||||
use Blax\Shop\Enums\ProductStatus;
|
use Blax\Shop\Enums\ProductStatus;
|
||||||
use Blax\Shop\Enums\ProductType;
|
use Blax\Shop\Enums\ProductType;
|
||||||
use Blax\Shop\Models\Cart;
|
use Blax\Shop\Models\Cart;
|
||||||
|
use Blax\Shop\Models\CartItem;
|
||||||
|
use Blax\Shop\Models\Order;
|
||||||
use Blax\Shop\Models\Product;
|
use Blax\Shop\Models\Product;
|
||||||
use Blax\Shop\Models\ProductCategory;
|
use Blax\Shop\Models\ProductCategory;
|
||||||
use Blax\Shop\Tests\TestCase;
|
use Blax\Shop\Tests\TestCase;
|
||||||
|
|
@ -83,4 +86,136 @@ class CommandListTest extends TestCase
|
||||||
$this->assertStringContainsString('<guest>', $output);
|
$this->assertStringContainsString('<guest>', $output);
|
||||||
$this->assertStringContainsString('Showing 1 cart', $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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
|
|
@ -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 = [];
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue