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