Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[8.x] Add scheduler integration tests #39862

Merged
merged 10 commits into from Dec 2, 2021
140 changes: 140 additions & 0 deletions tests/Integration/Console/CallbackSchedulingTest.php
@@ -0,0 +1,140 @@
<?php

namespace Illuminate\Tests\Integration\Console;

use Illuminate\Cache\ArrayStore;
use Illuminate\Cache\Repository;
use Illuminate\Console\Events\ScheduledTaskFailed;
use Illuminate\Console\Scheduling\CacheEventMutex;
use Illuminate\Console\Scheduling\CacheSchedulingMutex;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\SchedulingMutex;
use Illuminate\Container\Container;
use Illuminate\Contracts\Cache\Factory;
use Illuminate\Contracts\Events\Dispatcher;
use Orchestra\Testbench\TestCase;
use RuntimeException;

class CallbackSchedulingTest extends TestCase
{
protected $log = [];

protected function setUp(): void
{
parent::setUp();

$cache = new class implements Factory
{
public $store;

public function __construct()
{
$this->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);
}
}
209 changes: 209 additions & 0 deletions tests/Integration/Console/CommandSchedulingTest.php
@@ -0,0 +1,209 @@
<?php

namespace Illuminate\Tests\Integration\Console;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use Orchestra\Testbench\TestCase;

class CommandSchedulingTest extends TestCase
{
/**
* Each run of this test is assigned a random ID to ensure that separate runs
* do not interfere with each other.
*
* @var string
*/
protected $id;

/**
* The path to the file that execution logs will be written to.
*
* @var string
*/
protected $logfile;

/**
* Just in case Testbench starts to ship an `artisan` script, we'll check and save a backup.
*
* @var string|null
*/
protected $originalArtisan;

/**
* The Filesystem instance for writing stubs and logs.
*
* @var \Illuminate\Filesystem\Filesystem
*/
protected $fs;

protected function setUp(): void
{
parent::setUp();

$this->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 = <<<PHP
#!/usr/bin/env php
<?php

// This is a custom artisan script made specifically for:
//
// {$thisFile}
//
// It should be automatically cleaned up when the tests have finished executing.
// If you are seeing this file, an unexpected error must have occurred. Please
// manually remove it.

define('LARAVEL_START', microtime(true));

require __DIR__.'/../../../autoload.php';

\$app = require_once __DIR__.'/bootstrap/app.php';
\$kernel = \$app->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);
}
}