Skip to content

Commit

Permalink
[8.x] Add scheduler integration tests (#39862)
Browse files Browse the repository at this point in the history
* Add scheduler test for callback events

* Add exception handling test

* Added integration test for command scheduler

* Move everything to our `artisan` script

* Add a cache implementation

* Adjust container call

* Restore original artisan command just in case

* Code style

* Clear container during teardown

* Formatting
  • Loading branch information
inxilpro committed Dec 2, 2021
1 parent c8319a8 commit f4f8e32
Show file tree
Hide file tree
Showing 2 changed files with 349 additions and 0 deletions.
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);
}
}

0 comments on commit f4f8e32

Please sign in to comment.