diff --git a/composer.json b/composer.json index 5886735..f75c7b5 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,7 @@ "require-dev": { "blitz-php/coding-standard": "^1.4", "blitz-php/framework": "^0.11.3", + "blitz-php/http-client": "^0.0.2", "kahlan/kahlan": "^6.0", "phpstan/phpstan": "^2.1" }, diff --git a/spec/FrequenciesTrait.spec.php b/spec/FrequenciesTrait.spec.php index 0d56b08..671df7c 100644 --- a/spec/FrequenciesTrait.spec.php +++ b/spec/FrequenciesTrait.spec.php @@ -10,6 +10,7 @@ */ use BlitzPHP\Tasks\FrequenciesTrait; +use BlitzPHP\Utilities\Date; use function Kahlan\expect; @@ -41,8 +42,13 @@ it('testDailyWithTime', function () { $this->class->daily('4:08 pm'); + expect('08 16 * * *')->toBe($this->class->getExpression()); + $this->class->at('4:08 pm'); expect('08 16 * * *')->toBe($this->class->getExpression()); + + $this->class->at('04:28'); + expect('28 4 * * *')->toBe($this->class->getExpression()); }); it('testTime', function () { @@ -233,8 +239,19 @@ it('testEveryHourWithHour', function () { $this->class->everyHour(3); + $this->assertSame('0 */3 * * *', $this->class->getExpression()); + $this->class->everyTwoHours(); + $this->assertSame('0 */2 * * *', $this->class->getExpression()); + + $this->class->everyThreeHours(); $this->assertSame('0 */3 * * *', $this->class->getExpression()); + + $this->class->everyFourHours(); + $this->assertSame('0 */4 * * *', $this->class->getExpression()); + + $this->class->everySixHours(); + $this->assertSame('0 */6 * * *', $this->class->getExpression()); }); it('testEveryHourWithHourAndMinutes', function () { @@ -243,6 +260,12 @@ $this->assertSame('15 */3 * * *', $this->class->getExpression()); }); + it('testEveryOddHour', function () { + $this->class->everyOddHour(); + + $this->assertSame('0 1-23/2 * * *', $this->class->getExpression()); + }); + it('testBetweenHours', function () { $this->class->betweenHours(10, 12); @@ -275,8 +298,22 @@ it('testEveryMinuteWithParameter', function () { $this->class->everyMinute(15); - $this->assertSame('*/15 * * * *', $this->class->getExpression()); + + $this->class->everyTwoMinutes(); + $this->assertSame('*/2 * * * *', $this->class->getExpression()); + + $this->class->everyThreeMinutes(); + $this->assertSame('*/3 * * * *', $this->class->getExpression()); + + $this->class->everyFourMinutes(); + $this->assertSame('*/4 * * * *', $this->class->getExpression()); + + $this->class->everyTenMinutes(); + $this->assertSame('*/10 * * * *', $this->class->getExpression()); + + $this->class->everyThirtyMinutes(); + $this->assertSame('*/30 * * * *', $this->class->getExpression()); }); it('testBetweenMinutes', function () { @@ -303,6 +340,13 @@ $this->assertSame('* * 1,15 * *', $this->class->getExpression()); }); + it('testLastDayOfMonth', function () { + $this->class->lastDayOfMonth(); + $lastDay = Date::now()->endOfMonth()->getDay(); + + $this->assertSame('0 0 ' . $lastDay . ' * *', $this->class->getExpression()); + }); + it('testMonths', function () { $this->class->months([1, 7]); @@ -314,4 +358,10 @@ $this->assertSame('* * * 1-7 *', $this->class->getExpression()); }); + + it('testBetween', function () { + $this->class->between('10:00', '12:30'); + + $this->assertSame('0-30 10-12 * * *', $this->class->getExpression()); + }); }); diff --git a/spec/HooksTrait.spec.php b/spec/HooksTrait.spec.php new file mode 100644 index 0000000..9a2aca6 --- /dev/null +++ b/spec/HooksTrait.spec.php @@ -0,0 +1,147 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +use BlitzPHP\Tasks\Task; + +use function Kahlan\expect; + +describe('HookTrait', function () { + it('testAfter et testBefore', function () { + expect(file_exists($path = __DIR__.'/test-hook.txt'))->toBeFalsy(); + + $task = new Task('closure', fn() => 'Hello'); + $task->before(function() use($path) { + file_put_contents($path, 'before'); + }); + + $task->after(function() use($path) { + file_put_contents($path, 'after', FILE_APPEND); + }); + + $result = $task->run(); + + expect(file_exists($path))->toBeTruthy(); + $content = file_get_contents($path); + + expect(str_contains($content, 'before'))->toBeTruthy(); + expect(str_contains($content, 'after'))->toBeTruthy(); + expect($result)->toBe('Hello'); + + unlink($path); + }); + + it('sendOutputTo', function () { + $task = new Task('closure', function() { + echo 'Hello'; + + return 'world'; + }); + + $task->sendOutputTo($path = __DIR__ . '/output.txt'); + + $result = $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('Hello'); + expect($result)->toBe('world'); + + unlink($path); + }); + + it('appendOutputTo', function () { + $task = new Task('closure', function() { + echo 'Hello'; + }); + + $task->appendOutputTo($path = __DIR__ . '/output.txt'); + + file_put_contents($path, 'World'); + + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('WorldHello'); + + unlink($path); + }); + + it('onSuccess', function () { + $task = new Task('closure', fn() => 'Hello'); + + expect(file_exists($path = __DIR__.'/test-hook.txt'))->toBeFalsy(); + + $task->onSuccess(function() use($path) { + file_put_contents($path, 'Success!'); + }); + + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('Success!'); + + unlink($path); + }); + + it('onFailure', function () { + $task = new Task('closure', function() { + throw new Exception('Error'); + }); + + expect(file_exists($path = __DIR__.'/test-hook.txt'))->toBeFalsy(); + + $task->onFailure(function() use($path) { + file_put_contents($path, 'Failure!'); + }); + + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('Failure!'); + + unlink($path); + }); + + it('onFailure lorsqu\'il y\'a pas exception mais on renvoie un code d\'erreur', function () { + $task = new Task('closure', fn() => EXIT_ERROR); + + expect(file_exists($path = __DIR__.'/test-hook.txt'))->toBeFalsy(); + + $task->onFailure(function() use($path) { + file_put_contents($path, 'Failure!'); + }); + + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('Failure!'); + + unlink($path); + }); + + it('onFailure avec recuperation de l\'exception', function () { + $task = new Task('closure', function() { + throw new Exception('Error'); + }); + + expect(file_exists($path = __DIR__.'/test-hook.txt'))->toBeFalsy(); + + $task->onFailure(function(Throwable $e) use($path) { + file_put_contents($path, $e->getMessage()); + }); + + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('Error'); + + unlink($path); + }); +}); diff --git a/spec/Scheduler.spec.php b/spec/Scheduler.spec.php index 8dd0f8b..a4e8993 100644 --- a/spec/Scheduler.spec.php +++ b/spec/Scheduler.spec.php @@ -91,5 +91,22 @@ public function __invoke(Container $container) expect($task)->toBeAnInstanceOf(Task::class); expect('foo:bar')->toBe($task->getAction()); + expect('shell')->toBe($task->getType()); + }); + + it('Peut sauvegarder un evenement', function () { + $task = $this->scheduler->event('foo.bar'); + + expect($task)->toBeAnInstanceOf(Task::class); + expect('foo.bar')->toBe($task->getAction()); + expect('event')->toBe($task->getType()); + }); + + it('Peut sauvegarder un appel d\'URL', function () { + $task = $this->scheduler->url('http://localhost'); + + expect($task)->toBeAnInstanceOf(Task::class); + expect('http://localhost')->toBe($task->getAction()); + expect('url')->toBe($task->getType()); }); }); diff --git a/spec/Task.spec.php b/spec/Task.spec.php index b938835..ad021df 100644 --- a/spec/Task.spec.php +++ b/spec/Task.spec.php @@ -13,6 +13,7 @@ use BlitzPHP\Spec\ReflectionHelper; use BlitzPHP\Tasks\Task; use BlitzPHP\Utilities\Date; +use BlitzPHP\Utilities\Helpers; use function Kahlan\expect; @@ -62,6 +63,17 @@ $task = new Task('command', 'foo:bar'); expect($task->getType())->toBe('command'); + expect($task->type)->toBe('command'); + }); + + it('__set', function () { + $task = new Task('command', 'foo:bar'); + + $task->fake = 'foo:bar'; + + $attributes = ReflectionHelper::getPrivateProperty($task, 'attributes'); + + expect($attributes)->toBe(['fake' => 'foo:bar']); }); it("Execution d'une commande", function () { @@ -79,6 +91,10 @@ expect($task->shouldRun('12:05am'))->toBeFalsy(); expect($task->shouldRun('12:00am'))->toBeTruthy(); + + $task = (new Task('command', 'tasks:test'))->hourly()->environments('production'); + + expect($task->shouldRun('12:00am'))->toBeFalsy(); }); it("Peut s'executer dans un environnement donné", function () { @@ -120,4 +136,45 @@ expect($task->lastRun())->toBeAnInstanceOf(Date::class); // @phpstan-ignore-line expect($task->lastRun()->format('Y-m-d H:i:s'))->toBe($date); }); + + it('Peut executer une commande shell', function () { + expect(file_exists($path = __DIR__ . '/test.php'))->toBeFalsy(); + + $task = new Task('shell', 'cp ' . __FILE__ . ' ' . $path); + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + + $task = new Task('shell', 'rm ' . $path); + $task->run(); + + expect(file_exists($path))->toBeFalsy(); + }); + + it('Peut executer un evenement', function () { + expect(file_exists($path = __DIR__ . '/test.txt'))->toBeFalsy(); + + service('event')->on($event = 'test.event', function() use($path) { + file_put_contents($path, 'event.txt'); + }); + + $task = new Task('event', $event); + $task->run(); + + expect(file_exists($path))->toBeTruthy(); + expect(file_get_contents($path))->toBe('event.txt'); + + unlink($path); + }); + + it('Peut executer une URL', function () { + skipIf(! Helpers::isConnected()); + + $task = new Task('url', 'https://raw.githubusercontent.com/blitz-php/tasks/refs/heads/main/composer.json'); + $result = $task->run(); + $result = json_decode($result, true); + + expect($result)->toContainKey('name'); + expect($result['name'])->toBe('blitz-php/tasks'); + }); }); diff --git a/src/FrequenciesTrait.php b/src/FrequenciesTrait.php index eea9b28..ca2e581 100644 --- a/src/FrequenciesTrait.php +++ b/src/FrequenciesTrait.php @@ -117,7 +117,7 @@ public function time(string $time): self */ public function hourly(?int $minute = null): self { - return $this->everyHour(1, $minute); + return $this->everyHour(1, $minute ?? '00'); } /** diff --git a/src/HooksTrait.php b/src/HooksTrait.php new file mode 100644 index 0000000..2998dab --- /dev/null +++ b/src/HooksTrait.php @@ -0,0 +1,320 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace BlitzPHP\Tasks; + +use BlitzPHP\Contracts\Container\ContainerInterface; +use BlitzPHP\Contracts\Mail\MailerInterface; +use BlitzPHP\Utilities\Iterable\Arr; +use BlitzPHP\Utilities\String\Stringable; +use Closure; +use Throwable; + +/** + * + */ +trait HooksTrait +{ + /** + * L'emplacement où la sortie doit être envoyée. + */ + public ?string $location = null; + + /** + * Code de sortie de la tache + */ + protected ?int $exitCode = null; + + /** + * Exception levée lors de l'exécution de la tâche. + */ + protected ?Throwable $exception = null; + + /** + * Indique si la sortie doit être ajoutée. + */ + public bool $shouldAppendOutput = false; + + /** + * Tableau de rappels à exécuter avant l'execution de la tâche. + * + * @var list + */ + protected array $beforeCallbacks = []; + + /** + * Tableau de rappels à exécuter après l'execution de la tâche. + * + * @var list + */ + protected $afterCallbacks = []; + + /** + * Met la sortie de la tâche dans un fichier donné. + */ + public function sendOutputTo(string $location, bool $append = false): self + { + $this->location = $location; + $this->shouldAppendOutput = $append; + + return $this; + } + + /** + * Ajoute la sortie de la tâche à la fin d'un fichier donné. + */ + public function appendOutputTo(string $location): self + { + return $this->sendOutputTo($location, true); + } + + /** + * Envoi le resultat de l'execution de la tache par mail. + * + * @param array|mixed $addresses + * + * @throws \LogicException + */ + public function emailOutputTo($addresses, bool $onlyIfOutputExists = false): self + { + $this->ensureOutputIsBeingCaptured(); + + $addresses = Arr::wrap($addresses); + + return $this->then(function (MailerInterface $mailer) use ($addresses, $onlyIfOutputExists) { + $this->emailOutput($mailer, $addresses, $onlyIfOutputExists); + }); + } + + /** + * Envoi le resultat de l'execution de la tache par mail si un resultat existe dans la sortie. + * + * @param array|mixed $addresses + * + * @throws \LogicException + */ + public function emailWrittenOutputTo($addresses): self + { + return $this->emailOutputTo($addresses, true); + } + + /** + * Envoi le resultat de l'execution de la tache par mail si l'operation a echouée. + * + * @param array|mixed $addresses + */ + public function emailOutputOnFailure($addresses): self + { + $this->ensureOutputIsBeingCaptured(); + + $addresses = Arr::wrap($addresses); + + return $this->onFailure(function (MailerInterface $mailer) use ($addresses) { + $this->emailOutput($mailer, $addresses, false); + }); + } + + /** + * Enregistre un callback à appeler avant l'opération. + */ + public function before(Closure $callback): self + { + $this->beforeCallbacks[] = $callback; + + return $this; + } + + /** + * Enregistre un callback à appeler apres l'opération. + */ + public function after(Closure $callback): self + { + return $this->then($callback); + } + + /** + * Enregistre un callback à appeler apres l'opération. + */ + public function then(Closure $callback): self + { + $this->afterCallbacks[] = $callback; + + return $this; + } + + /** + * Enregistre un callback à appeler si l'opération se deroulle avec succes. + */ + public function onSuccess(Closure $callback): self + { + return $this->then(function (ContainerInterface $container) use ($callback) { + if ($this->exitCode === EXIT_SUCCESS) { + $container->call($callback); + } + }); + } + + /** + * Enregistre un callback à appeler si l'opération ne se deroulle pas correctement. + */ + public function onFailure(Closure $callback): self + { + return $this->then(function (ContainerInterface $container) use ($callback) { + if ($this->exitCode !== EXIT_SUCCESS) { + $container->call($callback, array_filter([$this->exception])); + } + }); + } + + /** + * Procede a l'execution de la tache + */ + protected function process(ContainerInterface $container, $method): mixed + { + ob_start(); + + $result = $this->start($this->container, $method); + + // if (! $this->runInBackground) { + $result = $this->finish($this->container, $result); + + ob_end_flush(); + + return $result; + // } + } + + /** + * Demarre l'execution de la tache + * + * @return mixed Le resultat de l'execution de la tache + * + * @throws Throwable + */ + protected function start(ContainerInterface $container, string $runMethod): mixed + { + try { + $this->callBeforeCallbacks($container); + + return $this->execute($container, $runMethod); + } catch (Throwable $e) { + $this->registerException($e); + } + } + + /** + * Execute la tache. + * + * @return mixed Le resultat de l'execution de la tache + */ + protected function execute(ContainerInterface $container, string $runMethod): mixed + { + try { + $result = $this->{$runMethod}(); + + if (is_int($result)) { + $this->exitCode = $result; + } else { + $this->exitCode = EXIT_SUCCESS; + } + } catch (Throwable $e) { + $this->registerException($e); + } + + return $result ?? null; + } + + /** + * Marque l'execution de la tache comme terminée et lance les callbacks/nettoyages. + */ + protected function finish(ContainerInterface $container, mixed $result): mixed + { + try { + $output = $this->callAfterCallbacks($container, $result); + } finally { + if (isset($output) && $output !== '' && $this->location !== null) { + @file_put_contents($this->location, $output, $this->shouldAppendOutput ? FILE_APPEND : 0); + } + } + + return $result; + } + + /** + * Ensure that the command output is being captured. + * + * @return void + */ + protected function ensureOutputIsBeingCaptured() + { + if (is_null($this->output) || $this->output == $this->getDefaultOutput()) { + $this->sendOutputTo(storage_path('logs/schedule-'.sha1($this->mutexName()).'.log')); + } + } + + /** + * Envoie du résultat de l'execution de la tache par mail aux destinataires. + */ + protected function emailOutput(MailerInterface $mailer, array $addresses, bool $onlyIfOutputExists = false): void + { + $text = is_file($this->location) ? file_get_contents($this->location) : ''; + + if ($onlyIfOutputExists && empty($text)) { + return; + } + + $mailer->to($addresses)->subject($this->getEmailSubject())->text($text)->send(); + } + + /** + * Objet de l'e-mail pour les résultats de sortie. + */ + protected function getEmailSubject(): string + { + return "Sortie de la tâche planifiée pour [{$this->command}]"; + } + + /** + * Appelle tous les callbacks qui doivent être lancer "avant" l'exécution de la tâche. + */ + protected function callBeforeCallbacks(ContainerInterface $container): void + { + foreach ($this->beforeCallbacks as $callback) { + $container->call($callback); + } + } + + /** + * Appelle tous les callbacks qui doivent être lancer "apres" l'exécution de la tâche. + */ + protected function callAfterCallbacks(ContainerInterface $container, mixed $result = null): string + { + $parameters = ['result' => $result]; + + if ('' !== $output = ob_get_contents() ?: '') { + $parameters['output'] = new Stringable($output); + } + + foreach ($this->afterCallbacks as $callback) { + $container->call($callback, $parameters); + } + + return $output; + } + + protected function registerException(Throwable $e) + { + $this->exception = $e; + $this->exitCode = EXIT_ERROR; + } +} diff --git a/src/Task.php b/src/Task.php index 4d02bba..95a7480 100644 --- a/src/Task.php +++ b/src/Task.php @@ -35,6 +35,7 @@ class Task { use FrequenciesTrait; + use HooksTrait; /** * Types d'action supportés @@ -125,7 +126,7 @@ public function run() throw TasksException::invalidTaskType($this->type); } - return $this->{$method}(); + return $this->process($this->container, $method); } /** @@ -141,7 +142,7 @@ public function shouldRun(?string $testTime = null): bool } // Sommes-nous limités aux environnements? - if (! empty($this->environments) && ! $this->runsInEnvironment(environment())) { + if (! $this->runsInEnvironment(environment())) { return false; } @@ -199,12 +200,7 @@ public function lastRun() */ protected function runsInEnvironment(string $environment): bool { - // Si rien n'est spécifié, il doit s'exécuter - if (empty($this->environments)) { - return true; - } - - return in_array($environment, $this->environments, true); + return empty($this->environments) || in_array($environment, $this->environments, true); } /** diff --git a/src/Test/MockScheduler.php b/src/Test/MockScheduler.php deleted file mode 100644 index 75c749f..0000000 --- a/src/Test/MockScheduler.php +++ /dev/null @@ -1,36 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace BlitzPHP\Tasks\Test; - -use BlitzPHP\Tasks\Scheduler; -use BlitzPHP\Tasks\Task; - -/** - * Une classe wrapper pour tester le renvoi de MockTasks au lieu de Tasks. - * - * @credit CodeIgniter4 - CodeIgniter\Tasks\Test\MockScheduler - */ -class MockScheduler extends Scheduler -{ - /** - * @return MockTask - */ - protected function createTask(string $type, mixed $action): Task - { - $task = new MockTask($type, $action); - $this->tasks[] = $task; - - return $task; - } -} diff --git a/src/Test/MockTask.php b/src/Test/MockTask.php deleted file mode 100644 index 34ae252..0000000 --- a/src/Test/MockTask.php +++ /dev/null @@ -1,50 +0,0 @@ - - * - * For the full copyright and license information, please view - * the LICENSE file that was distributed with this source code. - */ - -namespace BlitzPHP\Tasks\Test; - -use BlitzPHP\Tasks\Exceptions\TasksException; -use BlitzPHP\Tasks\Task; - -/** - * Classe de test qui empêche l'appel des actions. - * - * @credit CodeIgniter4 - CodeIgniter\Tasks\Test\MockTask - */ -class MockTask extends Task -{ - /** - * Prétend exécuter l'action de cette tâche. - * - * @return mixed - * - * @throws TasksException - */ - public function run() - { - $method = 'run' . ucfirst($this->type); - if (! method_exists($this, $method)) { - throw TasksException::invalidTaskType($this->type); - } - - $_SESSION['tasks_cache'] = [$this->type, $this->action]; - - return [ - 'command' => 'success', - 'shell' => [], - 'closure' => 42, - 'event' => true, - 'url' => 'body', - ][$this->type]; - } -}