diff --git a/src/Illuminate/Cache/Console/ClearCommand.php b/src/Illuminate/Cache/Console/ClearCommand.php index e84cefae8d6c..8a3828ee6fbe 100755 --- a/src/Illuminate/Cache/Console/ClearCommand.php +++ b/src/Illuminate/Cache/Console/ClearCommand.php @@ -4,6 +4,7 @@ use Illuminate\Cache\CacheManager; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Filesystem\Filesystem; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; @@ -12,6 +13,7 @@ #[AsCommand(name: 'cache:clear')] class ClearCommand extends Command { + use ConfirmableTrait; /** * The console command name. * @@ -57,10 +59,14 @@ public function __construct(CacheManager $cache, Filesystem $files) /** * Execute the console command. * - * @return void + * @return int */ public function handle() { + if (! $this->confirmToProceed()) { + return Command::FAILURE; + } + $this->laravel['events']->dispatch( 'cache:clearing', [$this->argument('store'), $this->tags()] ); @@ -70,7 +76,9 @@ public function handle() $this->flushFacades(); if (! $successful) { - return $this->components->error('Failed to clear cache. Make sure you have the appropriate permissions.'); + $this->components->error('Failed to clear cache. Make sure you have the appropriate permissions.'); + + return Command::FAILURE; } $this->laravel['events']->dispatch( @@ -78,6 +86,8 @@ public function handle() ); $this->components->info('Application cache cleared successfully.'); + + return Command::SUCCESS; } /** @@ -141,6 +151,7 @@ protected function getOptions() { return [ ['tags', null, InputOption::VALUE_OPTIONAL, 'The cache tags you would like to clear', null], + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], ]; } } diff --git a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php index cedf0309dabc..bc87648533ad 100644 --- a/src/Illuminate/Foundation/Console/ConfigCacheCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigCacheCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Arr; @@ -13,12 +14,15 @@ #[AsCommand(name: 'config:cache')] class ConfigCacheCommand extends Command { + use ConfirmableTrait; + /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'config:cache'; + protected $signature = 'config:cache + {--force : Force the operation to run when in production}'; /** * The console command description. @@ -55,6 +59,10 @@ public function __construct(Filesystem $files) */ public function handle() { + if (! $this->confirmToProceed()) { + return; + } + $this->callSilent('config:clear'); $config = $this->getFreshConfiguration(); diff --git a/src/Illuminate/Foundation/Console/ConfigClearCommand.php b/src/Illuminate/Foundation/Console/ConfigClearCommand.php index e88e2432c034..b97b64eb7e05 100644 --- a/src/Illuminate/Foundation/Console/ConfigClearCommand.php +++ b/src/Illuminate/Foundation/Console/ConfigClearCommand.php @@ -3,12 +3,15 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Filesystem\Filesystem; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'config:clear')] class ConfigClearCommand extends Command { + use ConfirmableTrait; /** * The console command name. * @@ -45,12 +48,30 @@ public function __construct(Filesystem $files) /** * Execute the console command. * - * @return void + * @return int */ public function handle() { + if (! $this->confirmToProceed()) { + return Command::FAILURE; + } + $this->files->delete($this->laravel->getCachedConfigPath()); $this->components->info('Configuration cache cleared successfully.'); + + return Command::SUCCESS; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], + ]; } } diff --git a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php index 89629c5bc38e..ee1a0cc4f60a 100644 --- a/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php +++ b/src/Illuminate/Foundation/Console/ConsoleMakeCommand.php @@ -7,7 +7,13 @@ use Illuminate\Support\Stringable; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; + +use function Laravel\Prompts\confirm; +use function Laravel\Prompts\select; +use function Laravel\Prompts\text; #[AsCommand(name: 'make:command')] class ConsoleMakeCommand extends GeneratorCommand @@ -35,6 +41,13 @@ class ConsoleMakeCommand extends GeneratorCommand */ protected $type = 'Console command'; + /** + * Interactive command configuration. + * + * @var array + */ + protected $interactiveConfig = []; + /** * Replace the class name for the given stub. * @@ -48,7 +61,72 @@ protected function replaceClass($stub, $name) $command = $this->option('command') ?: 'app:'.(new Stringable($name))->classBasename()->kebab()->value(); - return str_replace(['dummy:command', '{{ command }}'], $command, $stub); + // Build full signature with arguments and options if interactive mode was used + if (! empty($this->interactiveConfig)) { + $signature = $this->buildSignature($command); + $description = $this->interactiveConfig['description'] ?? 'Command description'; + + $stub = str_replace(['dummy:command', '{{ command }}'], $signature, $stub); + $stub = str_replace('Command description', $description, $stub); + } else { + $stub = str_replace(['dummy:command', '{{ command }}'], $command, $stub); + } + + return $stub; + } + + /** + * Build the full command signature with arguments and options. + * + * @param string $baseSignature + * @return string + */ + protected function buildSignature($baseSignature) + { + $signature = $baseSignature; + + // Add arguments + foreach ($this->interactiveConfig['arguments'] ?? [] as $argument) { + $arg = $argument['name']; + + if ($argument['mode'] === 'optional') { + $arg = "{$arg}?"; + } elseif ($argument['mode'] === 'array') { + $arg = "{$arg}?*"; + } + + $arg .= ' : '.$argument['description']; + $signature .= " {{$arg}}"; + } + + // Add options + foreach ($this->interactiveConfig['options'] ?? [] as $option) { + $opt = '--'.$option['name']; + + if (isset($option['shortcut']) && $option['shortcut']) { + $opt = '-'.strtoupper($option['shortcut']).'|'.$opt; + } + + if ($option['mode'] === 'optional') { + $opt .= '='; + } elseif ($option['mode'] === 'required') { + $opt .= '='; + } elseif ($option['mode'] === 'array') { + $opt .= '=*'; + } + + $opt .= ' : '.$option['description']; + + if (isset($option['default']) && $option['mode'] === 'optional') { + // Escape single quotes in default value + $defaultValue = str_replace("'", "\\'", $option['default']); + $signature .= " {{$opt}} {{--{$option['name']}={$defaultValue}}}"; + } else { + $signature .= " {{$opt}}"; + } + } + + return $signature; } /** @@ -98,6 +176,197 @@ protected function getOptions() return [ ['force', 'f', InputOption::VALUE_NONE, 'Create the class even if the console command already exists'], ['command', null, InputOption::VALUE_OPTIONAL, 'The terminal command that will be used to invoke the class'], + ['interactive', 'i', InputOption::VALUE_NONE, 'Interactively build the command signature with arguments and options'], + ]; + } + + /** + * Interact with the user before validating the input. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function interact(InputInterface $input, OutputInterface $output) + { + parent::interact($input, $output); + + if ($this->option('interactive') && ! $this->isReservedName($this->getNameInput())) { + $this->interactivelyBuildCommand($input); + } + } + + /** + * Interact further with the user if they were prompted for missing arguments. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @param \Symfony\Component\Console\Output\OutputInterface $output + * @return void + */ + protected function afterPromptingForMissingArguments(InputInterface $input, OutputInterface $output) + { + if ($this->isReservedName($this->getNameInput())) { + return; + } + + // Only proceed with interactive mode if explicitly requested + if (! $this->option('interactive')) { + return; + } + + $this->interactivelyBuildCommand($input); + } + + /** + * Interactively build the command configuration. + * + * @param \Symfony\Component\Console\Input\InputInterface $input + * @return void + */ + protected function interactivelyBuildCommand(InputInterface $input) + { + // Get command signature + $signature = text( + label: 'What is the command signature?', + placeholder: 'e.g., app:send-report', + required: true, + validate: fn ($value) => match (true) { + empty($value) => 'Command signature is required.', + str_contains($value, ' ') => 'Signature should not contain spaces. Use arguments/options instead.', + ! preg_match('/^[a-z0-9:_-]+$/i', $value) => 'Signature can only contain letters, numbers, colons, hyphens, and underscores.', + default => null, + } + ); + + $input->setOption('command', $signature); + + // Get command description + $description = text( + label: 'What is the command description?', + placeholder: 'e.g., Send daily report to administrators', + required: true, + validate: fn ($value) => empty($value) ? 'Description is required.' : null + ); + + $this->interactiveConfig['description'] = $description; + $this->interactiveConfig['arguments'] = []; + $this->interactiveConfig['options'] = []; + + // Build arguments + while (confirm('Would you like to add an argument?', default: false)) { + $this->interactiveConfig['arguments'][] = $this->promptForArgument(); + } + + // Build options + while (confirm('Would you like to add an option?', default: false)) { + $this->interactiveConfig['options'][] = $this->promptForOption(); + } + } + + /** + * Prompt for argument details. + * + * @return array + */ + protected function promptForArgument() + { + $name = text( + label: 'Argument name?', + placeholder: 'e.g., user', + required: true, + validate: fn ($value) => match (true) { + empty($value) => 'Argument name is required.', + ! preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $value) => 'Argument name must start with a letter or underscore and contain only letters, numbers, and underscores.', + default => null, + } + ); + + $description = text( + label: 'Argument description?', + placeholder: 'e.g., The user ID to process', + required: true, + validate: fn ($value) => empty($value) ? 'Description is required.' : null + ); + + $modeMap = [ + 'Required' => 'required', + 'Optional' => 'optional', + 'Optional array (multiple values)' => 'array', ]; + + $selectedMode = select( + label: 'Is this argument required or optional?', + options: array_keys($modeMap), + default: 'Required' + ); + + $mode = $modeMap[$selectedMode]; + + return compact('name', 'description', 'mode'); + } + + /** + * Prompt for option details. + * + * @return array + */ + protected function promptForOption() + { + $name = text( + label: 'Option name?', + placeholder: 'e.g., queue', + required: true, + validate: fn ($value) => match (true) { + empty($value) => 'Option name is required.', + ! preg_match('/^[a-zA-Z_][a-zA-Z0-9_-]*$/', $value) => 'Option name must start with a letter or underscore and contain only letters, numbers, hyphens, and underscores.', + default => null, + } + ); + + $shortcut = text( + label: 'Option shortcut? (Optional, single letter)', + placeholder: 'e.g., Q', + required: false, + validate: fn ($value) => ($value && ! preg_match('/^[a-zA-Z]$/', $value)) + ? 'Shortcut must be a single letter.' + : null + ); + + $description = text( + label: 'Option description?', + placeholder: 'e.g., Queue the job execution', + required: true, + validate: fn ($value) => empty($value) ? 'Description is required.' : null + ); + + $modeMap = [ + 'Flag (no value)' => 'none', + 'Optional value' => 'optional', + 'Required value' => 'required', + 'Array (multiple values)' => 'array', + ]; + + $selectedMode = select( + label: 'What type of option is this?', + options: array_keys($modeMap), + default: 'Flag (no value)' + ); + + $mode = $modeMap[$selectedMode]; + $default = null; + + if (in_array($mode, ['optional', 'required'])) { + $defaultValue = text( + label: 'Default value? (Optional)', + placeholder: 'Leave empty for no default', + required: false + ); + + if ($defaultValue !== '' && $defaultValue !== null) { + $default = $defaultValue; + } + } + + return array_filter(compact('name', 'shortcut', 'description', 'mode', 'default'), fn ($v) => $v !== null && $v !== ''); } } diff --git a/src/Illuminate/Foundation/Console/EventCacheCommand.php b/src/Illuminate/Foundation/Console/EventCacheCommand.php index 4b6edf67d8ad..0718d1d74ddb 100644 --- a/src/Illuminate/Foundation/Console/EventCacheCommand.php +++ b/src/Illuminate/Foundation/Console/EventCacheCommand.php @@ -3,18 +3,22 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Foundation\Support\Providers\EventServiceProvider; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'event:cache')] class EventCacheCommand extends Command { + use ConfirmableTrait; + /** * The name and signature of the console command. * * @var string */ - protected $signature = 'event:cache'; + protected $signature = 'event:cache + {--force : Force the operation to run when in production}'; /** * The console command description. @@ -30,6 +34,10 @@ class EventCacheCommand extends Command */ public function handle() { + if (! $this->confirmToProceed()) { + return; + } + $this->callSilent('event:clear'); file_put_contents( diff --git a/src/Illuminate/Foundation/Console/RouteCacheCommand.php b/src/Illuminate/Foundation/Console/RouteCacheCommand.php index 9b8632af50b2..00269d4609fe 100644 --- a/src/Illuminate/Foundation/Console/RouteCacheCommand.php +++ b/src/Illuminate/Foundation/Console/RouteCacheCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Contracts\Console\Kernel as ConsoleKernelContract; use Illuminate\Filesystem\Filesystem; use Illuminate\Routing\RouteCollection; @@ -11,12 +12,15 @@ #[AsCommand(name: 'route:cache')] class RouteCacheCommand extends Command { + use ConfirmableTrait; + /** - * The console command name. + * The name and signature of the console command. * * @var string */ - protected $name = 'route:cache'; + protected $signature = 'route:cache + {--force : Force the operation to run when in production}'; /** * The console command description. @@ -51,6 +55,10 @@ public function __construct(Filesystem $files) */ public function handle() { + if (! $this->confirmToProceed()) { + return; + } + $this->callSilent('route:clear'); $routes = $this->getFreshApplicationRoutes(); diff --git a/src/Illuminate/Foundation/Console/RouteListCommand.php b/src/Illuminate/Foundation/Console/RouteListCommand.php index 36813d87d1a5..c87de1db30ea 100644 --- a/src/Illuminate/Foundation/Console/RouteListCommand.php +++ b/src/Illuminate/Foundation/Console/RouteListCommand.php @@ -255,6 +255,21 @@ protected function isFrameworkController(Route $route) ], true); } + /** + * Check if the route method matches the filter. + * + * @param string $routeMethod + * @param string $filterMethod + * @return bool + */ + protected function matchesMethod($routeMethod, $filterMethod) + { + $filterMethods = array_map('strtoupper', array_map('trim', explode(',', $filterMethod))); + $routeMethods = explode('|', $routeMethod); + + return ! empty(array_intersect($filterMethods, $routeMethods)); + } + /** * Filter the route by URI and / or name. * @@ -266,7 +281,7 @@ protected function filterRoute(array $route) if (($this->option('name') && ! Str::contains((string) $route['name'], $this->option('name'))) || ($this->option('action') && isset($route['action']) && is_string($route['action']) && ! Str::contains($route['action'], $this->option('action'))) || ($this->option('path') && ! Str::contains($route['uri'], $this->option('path'))) || - ($this->option('method') && ! Str::contains($route['method'], strtoupper($this->option('method')))) || + ($this->option('method') && ! $this->matchesMethod($route['method'], $this->option('method'))) || ($this->option('domain') && ! Str::contains((string) $route['domain'], $this->option('domain'))) || ($this->option('except-vendor') && $route['vendor']) || ($this->option('only-vendor') && ! $route['vendor'])) { diff --git a/src/Illuminate/Foundation/Console/ViewCacheCommand.php b/src/Illuminate/Foundation/Console/ViewCacheCommand.php index 3eacc26fd769..2e14b89528eb 100644 --- a/src/Illuminate/Foundation/Console/ViewCacheCommand.php +++ b/src/Illuminate/Foundation/Console/ViewCacheCommand.php @@ -3,6 +3,7 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Support\Collection; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Output\OutputInterface; @@ -12,12 +13,15 @@ #[AsCommand(name: 'view:cache')] class ViewCacheCommand extends Command { + use ConfirmableTrait; + /** * The name and signature of the console command. * * @var string */ - protected $signature = 'view:cache'; + protected $signature = 'view:cache + {--force : Force the operation to run when in production}'; /** * The console command description. @@ -33,6 +37,10 @@ class ViewCacheCommand extends Command */ public function handle() { + if (! $this->confirmToProceed()) { + return; + } + $this->callSilent('view:clear'); $this->paths()->each(function ($path) { diff --git a/src/Illuminate/Foundation/Console/ViewClearCommand.php b/src/Illuminate/Foundation/Console/ViewClearCommand.php index ec942bddb998..1e36f3c871d4 100644 --- a/src/Illuminate/Foundation/Console/ViewClearCommand.php +++ b/src/Illuminate/Foundation/Console/ViewClearCommand.php @@ -3,13 +3,16 @@ namespace Illuminate\Foundation\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Illuminate\Filesystem\Filesystem; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Input\InputOption; #[AsCommand(name: 'view:clear')] class ViewClearCommand extends Command { + use ConfirmableTrait; /** * The console command name. * @@ -46,12 +49,16 @@ public function __construct(Filesystem $files) /** * Execute the console command. * - * @return void + * @return int * * @throws \RuntimeException */ public function handle() { + if (! $this->confirmToProceed()) { + return Command::FAILURE; + } + $path = $this->laravel['config']['view.compiled']; if (! $path) { @@ -67,5 +74,19 @@ public function handle() } $this->components->info('Compiled views cleared successfully.'); + + return Command::SUCCESS; + } + + /** + * Get the console command options. + * + * @return array + */ + protected function getOptions() + { + return [ + ['force', null, InputOption::VALUE_NONE, 'Force the operation to run when in production'], + ]; } } diff --git a/src/Illuminate/Queue/Console/FlushFailedCommand.php b/src/Illuminate/Queue/Console/FlushFailedCommand.php index 80cc2006d968..21628414dd9a 100644 --- a/src/Illuminate/Queue/Console/FlushFailedCommand.php +++ b/src/Illuminate/Queue/Console/FlushFailedCommand.php @@ -3,17 +3,19 @@ namespace Illuminate\Queue\Console; use Illuminate\Console\Command; +use Illuminate\Console\ConfirmableTrait; use Symfony\Component\Console\Attribute\AsCommand; #[AsCommand(name: 'queue:flush')] class FlushFailedCommand extends Command { + use ConfirmableTrait; /** * The console command name. * * @var string */ - protected $signature = 'queue:flush {--hours= : The number of hours to retain failed job data}'; + protected $signature = 'queue:flush {--hours= : The number of hours to retain failed job data} {--force : Force the operation to run when in production}'; /** * The console command description. @@ -25,18 +27,24 @@ class FlushFailedCommand extends Command /** * Execute the console command. * - * @return void + * @return int */ public function handle() { + if (! $this->confirmToProceed()) { + return Command::FAILURE; + } + $this->laravel['queue.failer']->flush($this->option('hours')); if ($this->option('hours')) { $this->components->info("All jobs that failed more than {$this->option('hours')} hours ago have been deleted successfully."); - return; + return Command::SUCCESS; } $this->components->info('All failed jobs deleted successfully.'); + + return Command::SUCCESS; } } diff --git a/tests/Cache/ClearCommandTest.php b/tests/Cache/ClearCommandTest.php index 3c2a2bb2345c..a94300c827f0 100644 --- a/tests/Cache/ClearCommandTest.php +++ b/tests/Cache/ClearCommandTest.php @@ -68,6 +68,18 @@ public function testClearWithNoStoreArgument() $this->runCommand($this->command); } + public function testClearWithConfirmationRequired() + { + $this->files->shouldReceive('exists')->andReturn(true); + $this->files->shouldReceive('files')->andReturn([]); + + $this->cacheManager->shouldReceive('store')->once()->with(null)->andReturn($this->cacheRepository); + $this->cacheRepository->shouldReceive('flush')->once(); + + // Test with --force flag to bypass confirmation + $this->runCommand($this->command, ['--force' => true]); + } + public function testClearWithStoreArgument() { $this->files->shouldReceive('exists')->andReturn(true); @@ -152,4 +164,11 @@ public function call($command, array $arguments = []) { return 0; } + + protected function getDefaultConfirmCallback() + { + return function () { + return false; // Never prompt in tests + }; + } } diff --git a/tests/Database/DatabaseEloquentModelTest.php b/tests/Database/DatabaseEloquentModelTest.php index dd724ac2e389..9590db1d0c24 100755 --- a/tests/Database/DatabaseEloquentModelTest.php +++ b/tests/Database/DatabaseEloquentModelTest.php @@ -3215,6 +3215,30 @@ public function testDoesntThrowWhenTestingMissingAttributes() } } + public function testPreventAccessingMissingAttributesCanBeConfigured() + { + // Ensure we start with the default behavior (no exceptions) + $originalMode = Model::preventsAccessingMissingAttributes(); + Model::preventAccessingMissingAttributes(false); + + try { + $model = new EloquentModelStub(['id' => 1]); + $model->exists = true; + + // Should not throw when disabled + $this->assertNull($model->this_attribute_does_not_exist); + + // Now enable it + Model::preventAccessingMissingAttributes(true); + + // Should throw when enabled + $this->expectException(MissingAttributeException::class); + $model->this_attribute_does_not_exist; + } finally { + Model::preventAccessingMissingAttributes($originalMode); + } + } + protected function addMockConnection($model) { $model->setConnectionResolver($resolver = m::mock(ConnectionResolverInterface::class)); diff --git a/tests/Integration/Console/ConfirmationSafetyTest.php b/tests/Integration/Console/ConfirmationSafetyTest.php new file mode 100644 index 000000000000..c872e106fb54 --- /dev/null +++ b/tests/Integration/Console/ConfirmationSafetyTest.php @@ -0,0 +1,62 @@ +assertTrue(method_exists($flushCommand, 'confirmToProceed'), 'FlushFailedCommand should have confirmToProceed method'); + + $cacheCommand = new ClearCommand( + $this->app->make('cache'), + $this->app->make('files') + ); + $this->assertTrue(method_exists($cacheCommand, 'confirmToProceed'), 'ClearCommand should have confirmToProceed method'); + + $configCommand = new ConfigClearCommand($this->app->make('files')); + $this->assertTrue(method_exists($configCommand, 'confirmToProceed'), 'ConfigClearCommand should have confirmToProceed method'); + + $viewCommand = new ViewClearCommand($this->app->make('files')); + $this->assertTrue(method_exists($viewCommand, 'confirmToProceed'), 'ViewClearCommand should have confirmToProceed method'); + } + + public function test_commands_have_force_option() + { + // Verify that our commands now have the --force option for production + $flushCommand = new FlushFailedCommand(); + $flushCommand->setLaravel($this->app); + + $definition = $flushCommand->getDefinition(); + $this->assertTrue($definition->hasOption('force'), 'FlushFailedCommand should have --force option'); + + $cacheCommand = new ClearCommand( + $this->app->make('cache'), + $this->app->make('files') + ); + $cacheCommand->setLaravel($this->app); + + $definition = $cacheCommand->getDefinition(); + $this->assertTrue($definition->hasOption('force'), 'ClearCommand should have --force option'); + + $configCommand = new ConfigClearCommand($this->app->make('files')); + $configCommand->setLaravel($this->app); + + $definition = $configCommand->getDefinition(); + $this->assertTrue($definition->hasOption('force'), 'ConfigClearCommand should have --force option'); + + $viewCommand = new ViewClearCommand($this->app->make('files')); + $viewCommand->setLaravel($this->app); + + $definition = $viewCommand->getDefinition(); + $this->assertTrue($definition->hasOption('force'), 'ViewClearCommand should have --force option'); + } +} diff --git a/tests/Integration/Database/DatabaseServiceProviderEnvironmentConfigTest.php b/tests/Integration/Database/DatabaseServiceProviderEnvironmentConfigTest.php new file mode 100644 index 000000000000..8f784fbff591 --- /dev/null +++ b/tests/Integration/Database/DatabaseServiceProviderEnvironmentConfigTest.php @@ -0,0 +1,72 @@ +app['config']->set('database.eloquent.prevent_accessing_missing_attributes', env('DB_PREVENT_MISSING_ATTRIBUTES', false)); + + // Create and boot the service provider + $provider = new DatabaseServiceProvider($this->app); + $provider->boot(); + + // Assert that the configuration was applied + $this->assertTrue(Model::preventsAccessingMissingAttributes()); + } + + public function testEnvironmentVariableCanDisableFeature() + { + // Simulate environment variable set to false + $_ENV['DB_PREVENT_MISSING_ATTRIBUTES'] = 'false'; + + // Update config to read from environment + $this->app['config']->set('database.eloquent.prevent_accessing_missing_attributes', env('DB_PREVENT_MISSING_ATTRIBUTES', false)); + + // Create and boot the service provider + $provider = new DatabaseServiceProvider($this->app); + $provider->boot(); + + // Assert that the feature is disabled + $this->assertFalse(Model::preventsAccessingMissingAttributes()); + } + + public function testDefaultsToFalseWhenEnvironmentVariableNotSet() + { + // Ensure environment variable is not set + if (isset($_ENV['DB_PREVENT_MISSING_ATTRIBUTES'])) { + unset($_ENV['DB_PREVENT_MISSING_ATTRIBUTES']); + } + + // Update config to read from environment with default false + $this->app['config']->set('database.eloquent.prevent_accessing_missing_attributes', env('DB_PREVENT_MISSING_ATTRIBUTES', false)); + + // Create and boot the service provider + $provider = new DatabaseServiceProvider($this->app); + $provider->boot(); + + // Assert that the feature is disabled by default + $this->assertFalse(Model::preventsAccessingMissingAttributes()); + } +} diff --git a/tests/Integration/Generators/ConsoleMakeCommandTest.php b/tests/Integration/Generators/ConsoleMakeCommandTest.php index 9bede8fe5aa6..dbfce6ccda39 100644 --- a/tests/Integration/Generators/ConsoleMakeCommandTest.php +++ b/tests/Integration/Generators/ConsoleMakeCommandTest.php @@ -6,6 +6,8 @@ class ConsoleMakeCommandTest extends TestCase { protected $files = [ 'app/Console/Commands/FooCommand.php', + 'app/Console/Commands/InteractiveCommand.php', + 'app/Console/Commands/SendReportCommand.php', ]; public function testItCanGenerateConsoleFile() @@ -33,4 +35,54 @@ public function testItCanGenerateConsoleFileWithCommandOption() 'protected $signature = \'foo:bar\';', ], 'app/Console/Commands/FooCommand.php'); } + + public function testInteractiveOptionExists() + { + $this->artisan('make:command', ['--help']) + ->expectsOutputToContain('--interactive') + ->assertExitCode(0); + } + + public function testInteractiveModeGeneratesCommandWithArguments() + { + $this->artisan('make:command', ['name' => 'SendReportCommand', '--interactive' => true]) + ->expectsQuestion('What is the command signature?', 'report:send') + ->expectsQuestion('What is the command description?', 'Send daily reports to administrators') + ->expectsConfirmation('Would you like to add an argument?', 'yes') + ->expectsQuestion('Argument name?', 'recipient') + ->expectsQuestion('Argument description?', 'The recipient email address') + ->expectsChoice('Is this argument required or optional?', 'Required', ['Required', 'Optional', 'Optional array (multiple values)']) + ->expectsConfirmation('Would you like to add an argument?', 'no') + ->expectsConfirmation('Would you like to add an option?', 'no') + ->assertExitCode(0); + + $this->assertFileContains([ + 'namespace App\Console\Commands;', + 'class SendReportCommand extends Command', + 'protected $signature = \'report:send {recipient : The recipient email address}\';', + 'protected $description = \'Send daily reports to administrators\';', + ], 'app/Console/Commands/SendReportCommand.php'); + } + + public function testInteractiveModeGeneratesCommandWithOptions() + { + $this->artisan('make:command', ['name' => 'InteractiveCommand', '--interactive' => true]) + ->expectsQuestion('What is the command signature?', 'app:interactive') + ->expectsQuestion('What is the command description?', 'Interactive test command') + ->expectsConfirmation('Would you like to add an argument?', 'no') + ->expectsConfirmation('Would you like to add an option?', 'yes') + ->expectsQuestion('Option name?', 'queue') + ->expectsQuestion('Option shortcut? (Optional, single letter)', 'Q') + ->expectsQuestion('Option description?', 'Queue the command execution') + ->expectsChoice('What type of option is this?', 'Flag (no value)', ['Flag (no value)', 'Optional value', 'Required value', 'Array (multiple values)']) + ->expectsConfirmation('Would you like to add an option?', 'no') + ->assertExitCode(0); + + $this->assertFileContains([ + 'namespace App\Console\Commands;', + 'class InteractiveCommand extends Command', + 'protected $signature = \'app:interactive {-Q|--queue : Queue the command execution}\';', + 'protected $description = \'Interactive test command\';', + ], 'app/Console/Commands/InteractiveCommand.php'); + } } diff --git a/tests/Queue/FlushFailedCommandTest.php b/tests/Queue/FlushFailedCommandTest.php new file mode 100644 index 000000000000..8e24abf5ba0e --- /dev/null +++ b/tests/Queue/FlushFailedCommandTest.php @@ -0,0 +1,68 @@ +shouldReceive('environment')->andReturn('development'); + $app->shouldReceive('offsetGet')->with('queue.failer')->andReturn($failedProvider); + + $command->setLaravel($app); + + $failedProvider->shouldReceive('flush')->once()->with(null); + + // Test with --force flag to bypass confirmation + $this->runCommand($command, ['--force' => true]); + } + + public function testFlushWithHoursOption() + { + $command = new FlushFailedCommandTestStub; + $failedProvider = m::mock(FailedJobProviderInterface::class); + + // Create a mock application that simulates development environment + $app = m::mock(Application::class); + $app->shouldReceive('environment')->andReturn('development'); + $app->shouldReceive('offsetGet')->with('queue.failer')->andReturn($failedProvider); + + $command->setLaravel($app); + + $failedProvider->shouldReceive('flush')->once()->with('24'); + + // Test with --force flag and --hours option + $this->runCommand($command, ['--force' => true, '--hours' => '24']); + } + + protected function runCommand($command, $input = []) + { + return $command->run(new ArrayInput($input), new NullOutput); + } +} + +class FlushFailedCommandTestStub extends FlushFailedCommand +{ + public function call($command, array $arguments = []) + { + return 0; + } +} diff --git a/tests/Testing/Console/RouteListCommandTest.php b/tests/Testing/Console/RouteListCommandTest.php index 0146918e9327..6f16b6b6da73 100644 --- a/tests/Testing/Console/RouteListCommandTest.php +++ b/tests/Testing/Console/RouteListCommandTest.php @@ -145,7 +145,67 @@ public function testRouteCanBeFilteredByAction() ->expectsOutput(''); } - public function testDisplayRoutesExceptVendor() + public function testRouteCanBeFilteredByMethod() + { + $this->withoutDeprecationHandling(); + + $this->router->get('/get-route', function () { + return 'GET'; + }); + $this->router->post('/post-route', function () { + return 'POST'; + }); + $this->router->put('/put-route', function () { + return 'PUT'; + }); + + $this->artisan(RouteListCommand::class, ['--method' => 'GET']) + ->assertSuccessful() + ->expectsOutputToContain('get-route') + ->expectsOutputToContain('Showing [1] routes') + ->doesntExpectOutputToContain('post-route') + ->doesntExpectOutputToContain('put-route'); + } + + public function testRouteCanBeFilteredByMultipleMethods() + { + $this->withoutDeprecationHandling(); + + $this->router->get('/get-route', function () { + return 'GET'; + }); + $this->router->post('/post-route', function () { + return 'POST'; + }); + $this->router->put('/put-route', function () { + return 'PUT'; + }); + + $this->artisan(RouteListCommand::class, ['--method' => 'GET,POST']) + ->assertSuccessful() + ->expectsOutputToContain('get-route') + ->expectsOutputToContain('post-route') + ->expectsOutputToContain('Showing [2] routes') + ->doesntExpectOutputToContain('put-route'); + } + + public function testRouteFilterByMethodIsCaseInsensitive() + { + $this->withoutDeprecationHandling(); + + $this->router->get('/test-get', function () { + return 'test get'; + }); + $this->router->post('/test-post', function () { + return 'test post'; + }); + + // Test with lowercase 'get' - should match GET routes + $this->artisan(RouteListCommand::class, ['--method' => 'get']) + ->assertSuccessful() + ->expectsOutputToContain('Showing [1] routes') + ->doesntExpectOutputToContain('test-post'); + } public function testDisplayRoutesExceptVendor() { $this->router->get('foo/{user}', [FooController::class, 'show']); $this->router->view('view', 'blade.path');