diff --git a/tests/Integration/Console/CallbackSchedulingTest.php b/tests/Integration/Console/CallbackSchedulingTest.php new file mode 100644 index 000000000000..9ad046b1931b --- /dev/null +++ b/tests/Integration/Console/CallbackSchedulingTest.php @@ -0,0 +1,140 @@ +store = new Repository(new ArrayStore(true)); + } + + public function store($name = null) + { + return $this->store; + } + }; + + $container = Container::getInstance(); + + $container->instance(EventMutex::class, new CacheEventMutex($cache)); + $container->instance(SchedulingMutex::class, new CacheSchedulingMutex($cache)); + } + + protected function tearDown(): void + { + Container::setInstance(null); + + parent::tearDown(); + } + + /** + * @dataProvider executionProvider + */ + public function testExecutionOrder($background) + { + $event = $this->app->make(Schedule::class) + ->call($this->logger('call')) + ->after($this->logger('after 1')) + ->before($this->logger('before 1')) + ->after($this->logger('after 2')) + ->before($this->logger('before 2')); + + if ($background) { + $event->runInBackground(); + } + + $this->artisan('schedule:run'); + + $this->assertLogged('before 1', 'before 2', 'call', 'after 1', 'after 2'); + } + + public function testExceptionHandlingInCallback() + { + $event = $this->app->make(Schedule::class) + ->call($this->logger('call')) + ->name('test-event') + ->withoutOverlapping(); + + // Set up "before" and "after" hooks to ensure they're called + $event->before($this->logger('before'))->after($this->logger('after')); + + // Register a hook to validate that the mutex was initially created + $mutexWasCreated = false; + $event->before(function () use (&$mutexWasCreated, $event) { + $mutexWasCreated = $event->mutex->exists($event); + }); + + // We'll trigger an exception in an "after" hook to test exception handling + $event->after(function () { + throw new RuntimeException; + }); + + // Because exceptions are caught by the ScheduleRunCommand, we need to listen for + // the "failed" event to check whether our exception was actually thrown + $failed = false; + $this->app->make(Dispatcher::class) + ->listen(ScheduledTaskFailed::class, function (ScheduledTaskFailed $failure) use (&$failed, $event) { + if ($failure->task === $event) { + $failed = true; + } + }); + + $this->artisan('schedule:run'); + + // Hooks and execution should happn in correct order + $this->assertLogged('before', 'call', 'after'); + + // Our exception should have resulted in a failure event + $this->assertTrue($failed); + + // Validate that the mutex was originally created, but that it's since + // been removed (even though an exception was thrown) + $this->assertTrue($mutexWasCreated); + $this->assertFalse($event->mutex->exists($event)); + } + + public function executionProvider() + { + return [ + 'Foreground' => [false], + 'Background' => [true], + ]; + } + + protected function logger($message) + { + return function () use ($message) { + $this->log[] = $message; + }; + } + + protected function assertLogged(...$message) + { + $this->assertEquals($message, $this->log); + } +} diff --git a/tests/Integration/Console/CommandSchedulingTest.php b/tests/Integration/Console/CommandSchedulingTest.php new file mode 100644 index 000000000000..d29d2ae14500 --- /dev/null +++ b/tests/Integration/Console/CommandSchedulingTest.php @@ -0,0 +1,209 @@ +fs = new Filesystem; + + $this->id = Str::random(); + $this->logfile = storage_path("logs/command_scheduling_test_{$this->id}.log"); + + $this->writeArtisanScript(); + } + + protected function tearDown(): void + { + $this->fs->delete($this->logfile); + $this->fs->delete(base_path('artisan')); + + if (! is_null($this->originalArtisan)) { + $this->fs->put(base_path('artisan'), $this->originalArtisan); + } + + parent::tearDown(); + } + + /** + * @dataProvider executionProvider + */ + public function testExecutionOrder($background) + { + $event = $this->app->make(Schedule::class) + ->command("test:{$this->id}") + ->onOneServer() + ->after(function () { + $this->fs->append($this->logfile, "after\n"); + }) + ->before(function () { + $this->fs->append($this->logfile, "before\n"); + }); + + if ($background) { + $event->runInBackground(); + } + + // We'll trigger the scheduler three times to simulate multiple servers + $this->artisan('schedule:run'); + $this->artisan('schedule:run'); + $this->artisan('schedule:run'); + + if ($background) { + // Since our command is running in a separate process, we need to wait + // until it has finished executing before running our assertions. + $this->waitForLogMessages('before', 'handled', 'after'); + } + + $this->assertLogged('before', 'handled', 'after'); + } + + public function executionProvider() + { + return [ + 'Foreground' => [false], + 'Background' => [true], + ]; + } + + protected function waitForLogMessages(...$messages) + { + $tries = 0; + $sleep = 100000; // 100K microseconds = 0.1 second + $limit = 50; // 0.1s * 50 = 5 second wait limit + + do { + $log = $this->fs->get($this->logfile); + + if (Str::containsAll($log, $messages)) { + return; + } + + $tries++; + usleep($sleep); + } while ($tries < $limit); + } + + protected function assertLogged(...$messages) + { + $log = trim($this->fs->get($this->logfile)); + + $this->assertEquals(implode("\n", $messages), $log); + } + + protected function writeArtisanScript() + { + $path = base_path('artisan'); + + // Save existing artisan script if there is one + if ($this->fs->exists($path)) { + $this->originalArtisan = $this->fs->get($path); + } + + $thisFile = __FILE__; + $logfile = var_export($this->logfile, true); + + $script = <<make(Illuminate\Contracts\Console\Kernel::class); + +// Here is our custom command for the test +class CommandSchedulingTestCommand_{$this->id} extends Illuminate\Console\Command +{ + protected \$signature = 'test:{$this->id}'; + + public function handle() + { + \$logfile = {$logfile}; + (new Illuminate\Filesystem\Filesystem)->append(\$logfile, "handled\\n"); + } +} + +// Register command with Kernel +Illuminate\Console\Application::starting(function (\$artisan) { + \$artisan->add(new CommandSchedulingTestCommand_{$this->id}); +}); + +// Add command to scheduler so that the after() callback is trigger in our spawned process +Illuminate\Foundation\Application::getInstance() + ->booted(function (\$app) { + \$app->resolving(Illuminate\Console\Scheduling\Schedule::class, function(\$schedule) { + \$fs = new Illuminate\Filesystem\Filesystem; + \$schedule->command("test:{$this->id}") + ->after(function() use (\$fs) { + \$logfile = {$logfile}; + \$fs->append(\$logfile, "after\\n"); + }) + ->before(function() use (\$fs) { + \$logfile = {$logfile}; + \$fs->append(\$logfile, "before\\n"); + }); + }); + }); + +\$status = \$kernel->handle( + \$input = new Symfony\Component\Console\Input\ArgvInput, + new Symfony\Component\Console\Output\ConsoleOutput +); + +\$kernel->terminate(\$input, \$status); + +exit(\$status); + +PHP; + + $this->fs->put($path, $script); + } +}