diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 00e51f9..2145596 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ on: branches: - 'main' - '1.x' + - '1.next' pull_request: branches: - '*' diff --git a/composer.json b/composer.json index 206e117..b712fe5 100644 --- a/composer.json +++ b/composer.json @@ -4,12 +4,13 @@ "type": "cakephp-plugin", "require": { "php": "^8.1", - "cakephp/cakephp": "^5.0.0", + "cakephp/cakephp": "^5.0", "dragonmantank/cron-expression": "^3.3" }, "require-dev": { "cakephp/cakephp-codesniffer": "^5.0", - "phpunit/phpunit": "^10.1" + "mockery/mockery": "^1.6", + "phpunit/phpunit": "^10.5.5 || ^11.1.3" }, "license": "MIT", "autoload": { diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..d9c2274 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,19 @@ +parameters: + ignoreErrors: + - + message: '#^Call to an undefined method CakeScheduler\\Scheduler\\Scheduler\|Mockery\\LegacyMockInterface\:\:getEventManager\(\)\.$#' + identifier: method.notFound + count: 2 + path: tests/TestCase/Command/ScheduleRunCommandTest.php + + - + message: '#^Call to an undefined method CakeScheduler\\Scheduler\\Scheduler\|Mockery\\LegacyMockInterface\:\:shouldReceive\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/TestCase/Command/ScheduleRunCommandTest.php + + - + message: '#^Call to an undefined method Mockery\\ExpectationInterface\|Mockery\\HigherOrderMessage\:\:andReturn\(\)\.$#' + identifier: method.notFound + count: 8 + path: tests/TestCase/Command/ScheduleRunCommandTest.php diff --git a/phpstan.neon b/phpstan.neon index ff6d291..cd4f009 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,6 @@ +includes: + - phpstan-baseline.neon + parameters: paths: - src diff --git a/src/Command/ScheduleRunCommand.php b/src/Command/ScheduleRunCommand.php index e98417d..5aac625 100644 --- a/src/Command/ScheduleRunCommand.php +++ b/src/Command/ScheduleRunCommand.php @@ -6,8 +6,10 @@ use Cake\Command\Command; use Cake\Console\Arguments; use Cake\Console\ConsoleIo; +use CakeScheduler\Error\SchedulerStoppedException; use CakeScheduler\Scheduler\Event; use CakeScheduler\Scheduler\Scheduler; +use Throwable; class ScheduleRunCommand extends Command { @@ -34,7 +36,13 @@ public function execute(Arguments $args, ConsoleIo $io): int } $events->each(function (Event $event) use ($io): void { - $this->runEvent($event, $io); + $returnCode = $this->runEvent($event, $io); + + if ($returnCode === Scheduler::SHOULD_STOP_EXECUTION) { + $io->error('Error while executing command: ' . get_class($event->getCommand())); + + throw new SchedulerStoppedException('Scheduler was stopped by event listener'); + } }); return self::CODE_SUCCESS; @@ -48,8 +56,17 @@ public function execute(Arguments $args, ConsoleIo $io): int protected function runEvent(Event $event, ConsoleIo $io): ?int { $this->scheduler->dispatchEvent('CakeScheduler.beforeExecute', ['event' => $event]); - $result = $event->run($io); - $this->scheduler->dispatchEvent('CakeScheduler.afterExecute', ['event' => $event, 'result' => $result]); + try { + $result = $event->run($io); + $this->scheduler->dispatchEvent('CakeScheduler.afterExecute', ['event' => $event, 'result' => $result]); + } catch (Throwable $exception) { + $io->error($exception->getMessage()); + $event = $this->scheduler->dispatchEvent('CakeScheduler.errorExecute', [ + 'event' => $event, + 'exception' => $exception, + ]); + $result = $event->getResult(); + } return $result; } diff --git a/src/Error/SchedulerStoppedException.php b/src/Error/SchedulerStoppedException.php new file mode 100644 index 0000000..f635465 --- /dev/null +++ b/src/Error/SchedulerStoppedException.php @@ -0,0 +1,10 @@ +addCallable($command, $args); + } + try { if ($this->container !== null) { $commandObj = $this->container->get($command); @@ -81,6 +93,42 @@ protected function addCommand(CommandInterface $command, array $args = []): Even return $event; } + /** + * @param callable $callable + * @param array $args + * @return \CakeScheduler\Scheduler\Event + */ + protected function addCallable(callable $callable, array $args = []): Event + { + $command = new class ($callable, $args) extends Command { + /** + * @param callable $callable + * @param array $args + */ + public function __construct( + protected $callable, + protected array $args = [] + ) { + } + + /** + * @param \Cake\Console\Arguments $args + * @param \Cake\Console\ConsoleIo $io + * @return int|null + */ + public function execute(Arguments $args, ConsoleIo $io): ?int + { + array_push($this->args, $io); + + return call_user_func_array($this->callable, $this->args); + } + }; + $event = new Event($command, $args); + $this->events = $this->events->appendItem($event); + + return $event; + } + /** * @return \Cake\Collection\CollectionInterface */ diff --git a/tests/TestCase/Command/ScheduleRunCommandTest.php b/tests/TestCase/Command/ScheduleRunCommandTest.php index b603718..e2b8e62 100644 --- a/tests/TestCase/Command/ScheduleRunCommandTest.php +++ b/tests/TestCase/Command/ScheduleRunCommandTest.php @@ -4,10 +4,18 @@ namespace CakeScheduler\Test\TestCase\Command; use Cake\Collection\Collection; +use Cake\Console\Arguments; +use Cake\Console\ConsoleIo; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; +use Cake\Core\Container; +use Cake\Event\EventInterface; use Cake\TestSuite\TestCase; +use CakeScheduler\Error\SchedulerStoppedException; use CakeScheduler\Scheduler\Event; use CakeScheduler\Scheduler\Scheduler; +use Exception; +use Mockery; +use Mockery\LegacyMockInterface; use TestApp\Command\TestAppCommand; use TestPlugin\Command\TestPluginCommand; @@ -15,6 +23,8 @@ class ScheduleRunCommandTest extends TestCase { use ConsoleIntegrationTestTrait; + protected Scheduler|LegacyMockInterface $scheduler; + protected function setUp(): void { parent::setUp(); @@ -24,19 +34,17 @@ protected function setUp(): void 'TestApp\Application', [PLUGIN_TESTS . 'test_app' . DS . 'config'] ); + + $this->scheduler = Mockery::mock(Scheduler::class, [new Container()])->makePartial(); } public function testRunNoCommand(): void { $this->mockService(Scheduler::class, function () { - $schedulerMock = $this->getMockBuilder(Scheduler::class)->getMock(); - $collection = new Collection([]); - - $schedulerMock->expects($this->any()) - ->method('dueEvents') - ->willReturn($collection); + $this->scheduler->shouldReceive('dueEvents') + ->andReturn(new Collection([])); - return $schedulerMock; + return $this->scheduler; }); $this->exec('schedule:run'); @@ -47,16 +55,12 @@ public function testRunNoCommand(): void public function testRunSingleCommand(): void { $this->mockService(Scheduler::class, function () { - $schedulerMock = $this->getMockBuilder(Scheduler::class)->getMock(); - $event = new Event(new TestAppCommand(), []); $collection = new Collection([$event]); + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); - $schedulerMock->expects($this->any()) - ->method('dueEvents') - ->willReturn($collection); - - return $schedulerMock; + return $this->scheduler; }); $this->exec('schedule:run'); @@ -68,17 +72,14 @@ public function testRunSingleCommand(): void public function testRunMultipleCommands(): void { $this->mockService(Scheduler::class, function () { - $schedulerMock = $this->getMockBuilder(Scheduler::class)->getMock(); - $appEvent = new Event(new TestAppCommand(), []); $pluginEvent = new Event(new TestPluginCommand(), []); $collection = new Collection([$appEvent, $pluginEvent]); - $schedulerMock->expects($this->any()) - ->method('dueEvents') - ->willReturn($collection); + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); - return $schedulerMock; + return $this->scheduler; }); $this->exec('schedule:run'); @@ -92,16 +93,13 @@ public function testRunMultipleCommands(): void public function testRunSingleCommandWithArgsAndOptions(): void { $this->mockService(Scheduler::class, function () { - $schedulerMock = $this->getMockBuilder(Scheduler::class)->getMock(); - $event = new Event(new TestAppCommand(), ['somearg', '--myoption=someoption']); $collection = new Collection([$event]); - $schedulerMock->expects($this->any()) - ->method('dueEvents') - ->willReturn($collection); + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); - return $schedulerMock; + return $this->scheduler; }); $this->exec('schedule:run'); @@ -111,4 +109,144 @@ public function testRunSingleCommandWithArgsAndOptions(): void $this->assertOutputContains('with arg somearg'); $this->assertOutputContains('with option someoption'); } + + public function testRunSingleCommandWhichThrowsException(): void + { + $this->mockService(Scheduler::class, function () { + $command = new class () extends TestAppCommand { + public function execute(Arguments $args, ConsoleIo $io): void + { + throw new Exception('Test Exception'); + } + }; + + $event = new Event($command, []); + $collection = new Collection([$event]); + + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); + + return $this->scheduler; + }); + $this->exec('schedule:run'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Executing [TestApp\\Command\\TestAppCommand@anonymous'); + $this->assertErrorContains('Test Exception'); + } + + public function testRunSingleCommandWhichThrowsExceptionAndListenerStopsExecution(): void + { + $this->mockService(Scheduler::class, function () { + $command = new class () extends TestAppCommand { + public function execute(Arguments $args, ConsoleIo $io): void + { + throw new Exception('Test Exception'); + } + }; + + $event = new Event($command, []); + $collection = new Collection([$event]); + + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); + + $this->scheduler->getEventManager()->on('CakeScheduler.errorExecute', function (EventInterface $event) { + $event->setResult(Scheduler::SHOULD_STOP_EXECUTION); + }); + + return $this->scheduler; + }); + + $this->expectException(SchedulerStoppedException::class); + $this->expectExceptionMessage('Scheduler was stopped by event listener'); + $this->exec('schedule:run'); + } + + public function testRunMultipleCommandsAndLastOneFails(): void + { + $this->mockService(Scheduler::class, function () { + $appEvent = new Event(new TestAppCommand(), []); + $pluginEvent = new Event(new TestPluginCommand(), []); + $failCommand = new class () extends TestAppCommand { + public function execute(Arguments $args, ConsoleIo $io): void + { + throw new Exception('Test Exception'); + } + }; + $failEvent = new Event($failCommand, []); + $collection = new Collection([$appEvent, $pluginEvent, $failEvent]); + + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); + + return $this->scheduler; + }); + $this->exec('schedule:run'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Executing [TestApp\\Command\\TestAppCommand]'); + $this->assertOutputContains('Test App Command executed'); + $this->assertOutputContains('Executing [TestPlugin\\Command\\TestPluginCommand]'); + $this->assertOutputContains('Test Plugin Command executed'); + $this->assertOutputContains('Executing [TestApp\\Command\\TestAppCommand@anonymous'); + $this->assertErrorContains('Test Exception'); + } + + public function testRunMultipleCommandsAndSecondToLastOneFailsAndStopsExecution(): void + { + $this->mockService(Scheduler::class, function () { + $appEvent = new Event(new TestAppCommand(), []); + $pluginEvent = new Event(new TestPluginCommand(), []); + $failCommand = new class () extends TestAppCommand { + public function execute(Arguments $args, ConsoleIo $io): void + { + throw new Exception('Test Exception'); + } + }; + $failEvent = new Event($failCommand, []); + $collection = new Collection([$appEvent, $failEvent, $pluginEvent]); + + $this->scheduler->shouldReceive('dueEvents') + ->andReturn($collection); + + $this->scheduler->getEventManager()->on('CakeScheduler.errorExecute', function (EventInterface $event) { + $event->setResult(Scheduler::SHOULD_STOP_EXECUTION); + }); + + return $this->scheduler; + }); + + $this->expectException(SchedulerStoppedException::class); + $this->expectExceptionMessage('Scheduler was stopped by event listener'); + $this->exec('schedule:run'); + + $this->assertOutputContains('Executing [TestApp\\Command\\TestAppCommand]'); + $this->assertOutputContains('Test App Command executed'); + $this->assertOutputContains('Executing [TestApp\\Command\\TestAppCommand@anonymous'); + $this->assertErrorContains('Test Exception'); + + $this->assertOutputNotContains('Executing [TestPlugin\\Command\\TestPluginCommand]'); + $this->assertOutputNotContains('Test Plugin Command executed'); + } + + public function testRunSingleCallable(): void + { + $this->mockService(Scheduler::class, function () { + $scheduler = new Scheduler(new Container()); + $scheduler->execute(function ($a, $b, $c, ConsoleIo $io) { + $io->info('Sum'); + + return $a + $b + $c; + }, [1,2,3]); + + return $scheduler; + }); + + $this->exec('schedule:run'); + + $this->assertExitSuccess(); + $this->assertOutputContains('Executing [Cake\\Command\\Command@anonymous'); + $this->assertOutputContains('Sum'); + } } diff --git a/tests/TestCase/Scheduler/SchedulerTest.php b/tests/TestCase/Scheduler/SchedulerTest.php index 97d0b0b..95cfb23 100644 --- a/tests/TestCase/Scheduler/SchedulerTest.php +++ b/tests/TestCase/Scheduler/SchedulerTest.php @@ -6,6 +6,7 @@ use Cake\Chronos\Chronos; use Cake\Command\VersionCommand; use Cake\Console\Command\HelpCommand; +use Cake\Console\ConsoleIo; use Cake\Core\Container; use Cake\TestSuite\TestCase; use CakeScheduler\Scheduler\Scheduler; @@ -46,6 +47,17 @@ public function testAddMultipleDueEvents(): void Chronos::setTestNow('now'); } + public function testAddCallable(): void + { + $this->scheduler->execute(function ($a, $b, $c, ConsoleIo $io) { + $io->info('Sum'); + + return $a + $b + $c; + }, [1,2,3]); + $events = $this->scheduler->dueEvents(); + $this->assertFalse($events->isEmpty()); + } + public function testAddUnknownCommand(): void { $this->expectException(InvalidArgumentException::class);