diff --git a/composer.json b/composer.json
index 285abb52..836b55eb 100644
--- a/composer.json
+++ b/composer.json
@@ -37,6 +37,7 @@
"amphp/postgres": "v2.0.0",
"amphp/redis": "^2.0",
"amphp/socket": "^2.1.0",
+ "dragonmantank/cron-expression": "^3.6",
"egulias/email-validator": "^4.0",
"fakerphp/faker": "^1.23",
"kelunik/rate-limit": "^3.0",
diff --git a/src/App.php b/src/App.php
index 9071f6fb..2ec1a551 100644
--- a/src/App.php
+++ b/src/App.php
@@ -35,6 +35,7 @@
use Phenix\Http\Constants\Protocol;
use Phenix\Logging\LoggerFactory;
use Phenix\Runtime\Log;
+use Phenix\Scheduling\TimerRegistry;
use Phenix\Session\SessionMiddlewareFactory;
use function Amp\async;
@@ -116,6 +117,8 @@ public function run(): void
$this->isRunning = true;
+ TimerRegistry::run();
+
if ($this->serverMode === ServerMode::CLUSTER && $this->signalTrapping) {
async(function (): void {
Cluster::awaitTermination();
diff --git a/src/Events/EventServiceProvider.php b/src/Events/EventServiceProvider.php
index ac0ce1c5..1e1882ff 100644
--- a/src/Events/EventServiceProvider.php
+++ b/src/Events/EventServiceProvider.php
@@ -37,12 +37,10 @@ public function boot(): void
private function loadEvents(): void
{
- $eventPath = base_path('events');
+ $eventsPath = base_path('listen' . DIRECTORY_SEPARATOR . 'events.php');
- if (File::exists($eventPath)) {
- foreach (File::listFilesRecursively($eventPath, '.php') as $file) {
- require $file;
- }
+ if (File::exists($eventsPath)) {
+ require $eventsPath;
}
}
}
diff --git a/src/Facades/Schedule.php b/src/Facades/Schedule.php
new file mode 100644
index 00000000..64218c62
--- /dev/null
+++ b/src/Facades/Schedule.php
@@ -0,0 +1,24 @@
+bind(Storage::class);
+ }
+
+ public function boot(): void
+ {
$this->bind(FileContract::class, File::class);
}
}
diff --git a/src/Routing/RouteServiceProvider.php b/src/Routing/RouteServiceProvider.php
index ac2b23b2..caa759a6 100644
--- a/src/Routing/RouteServiceProvider.php
+++ b/src/Routing/RouteServiceProvider.php
@@ -4,6 +4,7 @@
namespace Phenix\Routing;
+use Phenix\Facades\File;
use Phenix\Providers\ServiceProvider;
use Phenix\Routing\Console\RouteList;
use Phenix\Util\Directory;
@@ -41,8 +42,10 @@ private function getControllersPath(): string
private function loadRoutes(): void
{
- foreach (Directory::all(base_path('routes')) as $file) {
- require $file;
+ $routesPath = base_path('routes' . DIRECTORY_SEPARATOR . 'api.php');
+
+ if (File::exists($routesPath)) {
+ require $routesPath;
}
}
}
diff --git a/src/Scheduling/Console/ScheduleRunCommand.php b/src/Scheduling/Console/ScheduleRunCommand.php
new file mode 100644
index 00000000..69985630
--- /dev/null
+++ b/src/Scheduling/Console/ScheduleRunCommand.php
@@ -0,0 +1,36 @@
+run();
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Scheduling/Console/ScheduleWorkCommand.php b/src/Scheduling/Console/ScheduleWorkCommand.php
new file mode 100644
index 00000000..f9040c7e
--- /dev/null
+++ b/src/Scheduling/Console/ScheduleWorkCommand.php
@@ -0,0 +1,36 @@
+daemon($output);
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Scheduling/Schedule.php b/src/Scheduling/Schedule.php
new file mode 100644
index 00000000..3ca20366
--- /dev/null
+++ b/src/Scheduling/Schedule.php
@@ -0,0 +1,44 @@
+
+ */
+ protected array $schedules = [];
+
+ public function timer(Closure $closure): Timer
+ {
+ $timer = new Timer($closure);
+
+ TimerRegistry::add($timer);
+
+ return $timer;
+ }
+
+ public function call(Closure $closure): Scheduler
+ {
+ $scheduler = new Scheduler($closure);
+
+ $this->schedules[] = $scheduler;
+
+ return $scheduler;
+ }
+
+ public function run(): void
+ {
+ $now = null;
+ foreach ($this->schedules as $scheduler) {
+ $now ??= Date::now('UTC');
+
+ $scheduler->tick($now);
+ }
+ }
+}
diff --git a/src/Scheduling/ScheduleWorker.php b/src/Scheduling/ScheduleWorker.php
new file mode 100644
index 00000000..c1fa4632
--- /dev/null
+++ b/src/Scheduling/ScheduleWorker.php
@@ -0,0 +1,77 @@
+writeln('Starting schedule worker...');
+
+ $this->listenSignals();
+
+ $lastRunKey = null;
+
+ while (true) {
+ if ($this->shouldQuit()) {
+ break;
+ }
+
+ $this->sleepMicroseconds(100_000); // 100ms
+
+ $now = $this->now();
+
+ if ($now->second !== 0) {
+ continue;
+ }
+
+ $currentKey = $now->format('Y-m-d H:i');
+
+ if ($currentKey === $lastRunKey) {
+ continue;
+ }
+
+ Schedule::run();
+
+ $lastRunKey = $currentKey;
+ }
+
+ $output?->writeln('Schedule worker stopped.');
+ }
+
+ public function shouldQuit(): bool
+ {
+ return $this->quit;
+ }
+
+ protected function sleepMicroseconds(int $microseconds): void
+ {
+ usleep($microseconds);
+ }
+
+ protected function now(): Date
+ {
+ return Date::now('UTC');
+ }
+
+ protected function listenSignals(): void
+ {
+ pcntl_async_signals(true);
+
+ pcntl_signal(SIGINT, function (): void {
+ $this->quit = true;
+ });
+
+ pcntl_signal(SIGTERM, function (): void {
+ $this->quit = true;
+ });
+ }
+}
diff --git a/src/Scheduling/Scheduler.php b/src/Scheduling/Scheduler.php
new file mode 100644
index 00000000..862ed31a
--- /dev/null
+++ b/src/Scheduling/Scheduler.php
@@ -0,0 +1,158 @@
+closure = weakClosure($closure);
+ }
+
+ public function hourly(): self
+ {
+ return $this->setExpressionString('@hourly');
+ }
+
+ public function daily(): self
+ {
+ return $this->setExpressionString('@daily');
+ }
+
+ public function weekly(): self
+ {
+ return $this->setExpressionString('@weekly');
+ }
+
+ public function monthly(): self
+ {
+ return $this->setExpressionString('@monthly');
+ }
+
+ public function everyMinute(): self
+ {
+ return $this->setExpressionString('* * * * *');
+ }
+
+ public function everyFiveMinutes(): self
+ {
+ return $this->setExpressionString('*/5 * * * *');
+ }
+
+ public function everyTenMinutes(): self
+ {
+ return $this->setExpressionString('*/10 * * * *');
+ }
+
+ public function everyFifteenMinutes(): self
+ {
+ return $this->setExpressionString('*/15 * * * *');
+ }
+
+ public function everyThirtyMinutes(): self
+ {
+ return $this->setExpressionString('*/30 * * * *');
+ }
+
+ public function everyTwoHours(): self
+ {
+ return $this->setExpressionString('0 */2 * * *');
+ }
+
+ public function everyTwoDays(): self
+ {
+ return $this->setExpressionString('0 0 */2 * *');
+ }
+
+ public function everyWeekday(): self
+ {
+ return $this->setExpressionString('0 0 * * 1-5');
+ }
+
+ public function everyWeekend(): self
+ {
+ return $this->setExpressionString('0 0 * * 6,0');
+ }
+
+ public function mondays(): self
+ {
+ return $this->setExpressionString('0 0 * * 1');
+ }
+
+ public function fridays(): self
+ {
+ return $this->setExpressionString('0 0 * * 5');
+ }
+
+ public function dailyAt(string $time): self
+ {
+ return $this->daily()->at($time);
+ }
+
+ public function weeklyAt(string $time): self
+ {
+ return $this->weekly()->at($time);
+ }
+
+ public function at(string $time): self
+ {
+ [$hour, $minute] = array_map('intval', explode(':', $time));
+
+ $expr = $this->expression?->getExpression() ?? '* * * * *';
+
+ $parts = explode(' ', $expr);
+
+ if (count($parts) === 5) {
+ $parts[0] = (string) $minute;
+ $parts[1] = (string) $hour;
+ }
+
+ $this->expression = new CronExpression(implode(' ', $parts));
+
+ return $this;
+ }
+
+ public function timezone(string $tz): self
+ {
+ $this->timezone = $tz;
+
+ return $this;
+ }
+
+ protected function setExpressionString(string $expression): self
+ {
+ $this->expression = new CronExpression($expression);
+
+ return $this;
+ }
+
+ public function tick(Date|null $now = null): void
+ {
+ if (! $this->expression) {
+ return;
+ }
+
+ $now ??= Date::now();
+ $localNow = $now->copy()->timezone($this->timezone);
+
+ if ($this->expression->isDue($localNow)) {
+ ($this->closure)();
+ }
+ }
+}
diff --git a/src/Scheduling/SchedulingServiceProvider.php b/src/Scheduling/SchedulingServiceProvider.php
new file mode 100644
index 00000000..e5ae6425
--- /dev/null
+++ b/src/Scheduling/SchedulingServiceProvider.php
@@ -0,0 +1,35 @@
+bind(Schedule::class)->setShared(true);
+ $this->bind(ScheduleWorker::class);
+
+ $this->commands([
+ ScheduleWorkCommand::class,
+ ScheduleRunCommand::class,
+ ]);
+
+ $this->loadSchedules();
+ }
+
+ private function loadSchedules(): void
+ {
+ $schedulePath = base_path('schedule' . DIRECTORY_SEPARATOR . 'schedules.php');
+
+ if (File::exists($schedulePath)) {
+ require $schedulePath;
+ }
+ }
+}
diff --git a/src/Scheduling/Timer.php b/src/Scheduling/Timer.php
new file mode 100644
index 00000000..ff122b47
--- /dev/null
+++ b/src/Scheduling/Timer.php
@@ -0,0 +1,158 @@
+closure = weakClosure($closure);
+ }
+
+ public function seconds(float $seconds): self
+ {
+ $this->interval = max(0.001, $seconds);
+
+ return $this;
+ }
+
+ public function milliseconds(int $milliseconds): self
+ {
+ $this->interval = max(0.001, $milliseconds / 1000);
+
+ return $this;
+ }
+
+ public function everySecond(): self
+ {
+ return $this->seconds(1);
+ }
+
+ public function everyTwoSeconds(): self
+ {
+ return $this->seconds(2);
+ }
+
+ public function everyFiveSeconds(): self
+ {
+ return $this->seconds(5);
+ }
+
+ public function everyTenSeconds(): self
+ {
+ return $this->seconds(10);
+ }
+
+ public function everyFifteenSeconds(): self
+ {
+ return $this->seconds(15);
+ }
+
+ public function everyThirtySeconds(): self
+ {
+ return $this->seconds(30);
+ }
+
+ public function everyMinute(): self
+ {
+ return $this->seconds(60);
+ }
+
+ public function everyTwoMinutes(): self
+ {
+ return $this->seconds(120);
+ }
+
+ public function everyFiveMinutes(): self
+ {
+ return $this->seconds(300);
+ }
+
+ public function everyTenMinutes(): self
+ {
+ return $this->seconds(600);
+ }
+
+ public function everyFifteenMinutes(): self
+ {
+ return $this->seconds(900);
+ }
+
+ public function everyThirtyMinutes(): self
+ {
+ return $this->seconds(1800);
+ }
+
+ public function hourly(): self
+ {
+ return $this->seconds(3600);
+ }
+
+ public function reference(): self
+ {
+ $this->reference = true;
+
+ if ($this->timer) {
+ $this->timer->reference();
+ }
+
+ return $this;
+ }
+
+ public function unreference(): self
+ {
+ $this->reference = false;
+
+ if ($this->timer) {
+ $this->timer->unreference();
+ }
+
+ return $this;
+ }
+
+ public function run(): self
+ {
+ $this->timer = new Interval($this->interval, $this->closure, $this->reference);
+
+ return $this;
+ }
+
+ public function enable(): self
+ {
+ if ($this->timer) {
+ $this->timer->enable();
+ }
+
+ return $this;
+ }
+
+ public function disable(): self
+ {
+ if ($this->timer) {
+ $this->timer->disable();
+ }
+
+ return $this;
+ }
+
+ public function isEnabled(): bool
+ {
+ return $this->timer?->isEnabled() ?? false;
+ }
+}
diff --git a/src/Scheduling/TimerRegistry.php b/src/Scheduling/TimerRegistry.php
new file mode 100644
index 00000000..f8e6fc22
--- /dev/null
+++ b/src/Scheduling/TimerRegistry.php
@@ -0,0 +1,27 @@
+
+ */
+ protected static array $timers = [];
+
+ public static function add(Timer $timer): void
+ {
+ self::$timers[] = $timer;
+ }
+
+ public static function run(): void
+ {
+ foreach (self::$timers as $timer) {
+ $timer->run();
+ }
+
+ self::$timers = [];
+ }
+}
diff --git a/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php b/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php
new file mode 100644
index 00000000..a207fbca
--- /dev/null
+++ b/tests/Unit/Scheduling/Console/ScheduleRunCommandTest.php
@@ -0,0 +1,21 @@
+everyMinute();
+
+ /** @var CommandTester $command */
+ $command = $this->phenix('schedule:run');
+
+ $command->assertCommandIsSuccessful();
+
+ expect($executed)->toBeTrue();
+});
diff --git a/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php b/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php
new file mode 100644
index 00000000..9dd7e4bd
--- /dev/null
+++ b/tests/Unit/Scheduling/Console/ScheduleWorkCommandTest.php
@@ -0,0 +1,47 @@
+getMockBuilder(ScheduleWorker::class)
+ ->disableOriginalConstructor()
+ ->getMock();
+
+ $worker->expects($this->once())
+ ->method('daemon');
+
+ $this->app->swap(ScheduleWorker::class, $worker);
+
+ /** @var CommandTester $command */
+ $command = $this->phenix('schedule:work');
+
+ $command->assertCommandIsSuccessful();
+});
+
+it('breaks execution when quit signal is received', function (): void {
+ $worker = $this->getMockBuilder(ScheduleWorker::class)
+ ->onlyMethods(['shouldQuit', 'sleepMicroseconds', 'listenSignals', 'now'])
+ ->getMock();
+
+ $worker->expects($this->once())
+ ->method('listenSignals');
+
+ $worker->expects($this->exactly(2))
+ ->method('shouldQuit')
+ ->willReturnOnConsecutiveCalls(false, true);
+
+ $worker->method('sleepMicroseconds');
+
+ $worker->method('now')->willReturn(Date::now('UTC')->startOfMinute());
+
+ $this->app->swap(ScheduleWorker::class, $worker);
+
+ /** @var CommandTester $command */
+ $command = $this->phenix('schedule:work');
+
+ $command->assertCommandIsSuccessful();
+});
diff --git a/tests/Unit/Scheduling/SchedulerTest.php b/tests/Unit/Scheduling/SchedulerTest.php
new file mode 100644
index 00000000..486f7fc6
--- /dev/null
+++ b/tests/Unit/Scheduling/SchedulerTest.php
@@ -0,0 +1,245 @@
+call(function () use (&$executed): void {
+ $executed = true;
+ })->everyMinute();
+
+ $now = Date::now('UTC')->startOfMinute()->addSeconds(30);
+
+ $scheduler->tick($now);
+
+ expect($executed)->toBeTrue();
+});
+
+it('does not execute when not due (dailyAt time mismatch)', function (): void {
+ $schedule = new Schedule();
+
+ $executed = false;
+
+ $scheduler = $schedule->call(function () use (&$executed): void {
+ $executed = true;
+ })->dailyAt('10:15');
+
+ $now = Date::now('UTC')->startOfMinute();
+
+ $scheduler->tick($now);
+
+ expect($executed)->toBeFalse();
+
+ $now2 = Date::now('UTC')->startOfMinute()->addMinute();
+
+ $scheduler->tick($now2);
+
+ expect($executed)->toBeFalse();
+});
+
+it('executes exactly at matching dailyAt time', function (): void {
+ $schedule = new Schedule();
+
+ $executed = false;
+
+ $scheduler = $schedule->call(function () use (&$executed): void {
+ $executed = true;
+ })->dailyAt('10:15');
+
+ $now = Date::now('UTC')->startOfMinute()->setTime(10, 15);
+
+ $scheduler->tick($now);
+
+ expect($executed)->toBeTrue();
+});
+
+it('respects timezone when evaluating due', function (): void {
+ $schedule = new Schedule();
+
+ $executed = false;
+
+ $scheduler = $schedule->call(function () use (&$executed): void {
+ $executed = true;
+ })->dailyAt('12:00')->timezone('America/New_York');
+
+ $now = Date::now('UTC')->startOfMinute()->setTime(17, 0);
+
+ $scheduler->tick($now);
+
+ expect($executed)->toBeTrue();
+});
+
+it('supports */5 minutes schedule and only runs on multiples of five', function (): void {
+ $schedule = new Schedule();
+
+ $executed = false;
+
+ $scheduler = $schedule->call(function () use (&$executed): void {
+ $executed = true;
+ })->everyFiveMinutes();
+
+ $notDue = Date::now('UTC')->startOfMinute()->setTime(10, 16);
+
+ $scheduler->tick($notDue);
+
+ expect($executed)->toBeFalse();
+
+ $due = Date::now('UTC')->startOfMinute()->setTime(10, 15);
+
+ $scheduler->tick($due);
+
+ expect($executed)->toBeTrue();
+});
+
+it('does nothing when no expression is set', function (): void {
+ $executed = false;
+
+ $scheduler = new Scheduler(function () use (&$executed): void {
+ $executed = true;
+ });
+
+ $now = Date::now('UTC')->startOfDay();
+
+ $scheduler->tick($now);
+
+ expect($executed)->toBeFalse();
+});
+
+it('sets cron for weekly', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->weekly();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 * * 0');
+});
+
+it('sets cron for monthly', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->monthly();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 1 * *');
+});
+
+it('sets cron for every ten minutes', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyTenMinutes();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('*/10 * * * *');
+});
+
+it('sets cron for every fifteen minutes', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyFifteenMinutes();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('*/15 * * * *');
+});
+
+it('sets cron for every thirty minutes', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyThirtyMinutes();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('*/30 * * * *');
+});
+
+it('sets cron for every two hours', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyTwoHours();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 */2 * * *');
+});
+
+it('sets cron for every two days', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyTwoDays();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 */2 * *');
+});
+
+it('sets cron for every weekday', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyWeekday();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 * * 1-5');
+});
+
+it('sets cron for every weekend', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->everyWeekend();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 * * 6,0');
+});
+
+it('sets cron for mondays', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->mondays();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 * * 1');
+});
+
+it('sets cron for fridays', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->fridays();
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('0 0 * * 5');
+});
+
+it('sets cron for weeklyAt at specific time', function (): void {
+ $scheduler = (new Schedule())->call(function (): void {})->weeklyAt('10:15');
+
+ $ref = new ReflectionClass($scheduler);
+ $prop = $ref->getProperty('expression');
+ $prop->setAccessible(true);
+ $expr = $prop->getValue($scheduler);
+
+ expect($expr->getExpression())->toBe('15 10 * * 0');
+});
diff --git a/tests/Unit/Scheduling/TimerTest.php b/tests/Unit/Scheduling/TimerTest.php
new file mode 100644
index 00000000..19348d1f
--- /dev/null
+++ b/tests/Unit/Scheduling/TimerTest.php
@@ -0,0 +1,276 @@
+timer(function () use (&$count): void {
+ $count++;
+ })->everySecond();
+
+ $timer->reference();
+
+ TimerRegistry::run();
+
+ delay(2.2);
+
+ expect($count)->toBeGreaterThanOrEqual(2);
+
+ $timer->disable();
+
+ $afterDisable = $count;
+
+ delay(1.5);
+
+ expect($count)->toBe($afterDisable);
+});
+
+it('can be re-enabled after disable', function (): void {
+ $schedule = new Schedule();
+
+ $count = 0;
+
+ $timer = $schedule->timer(function () use (&$count): void {
+ $count++;
+ })->everySecond();
+
+ TimerRegistry::run();
+
+ delay(1.1);
+
+ expect($count)->toBeGreaterThanOrEqual(1);
+
+ $timer->disable();
+
+ $paused = $count;
+
+ delay(1.2);
+
+ expect($count)->toBe($paused);
+
+ $timer->enable();
+
+ delay(1.2);
+
+ expect($count)->toBeGreaterThan($paused);
+
+ $timer->disable();
+});
+
+it('supports millisecond intervals', function (): void {
+ $schedule = new Schedule();
+
+ $count = 0;
+
+ $timer = $schedule->timer(function () use (&$count): void {
+ $count++;
+ })->milliseconds(100);
+
+ TimerRegistry::run();
+
+ delay(0.35);
+
+ expect($count)->toBeGreaterThanOrEqual(2);
+
+ $timer->disable();
+});
+
+it('unreference does not prevent execution', function (): void {
+ $schedule = new Schedule();
+
+ $executed = false;
+
+ $timer = $schedule->timer(function () use (&$executed): void {
+ $executed = true;
+ })->everySecond()->unreference();
+
+ TimerRegistry::run();
+
+ delay(1.2);
+
+ expect($executed)->toBeTrue();
+
+ $timer->disable();
+});
+
+it('reports enabled state correctly', function (): void {
+ $schedule = new Schedule();
+
+ $timer = $schedule->timer(function (): void {
+ // no-op
+ })->everySecond();
+
+ expect($timer->isEnabled())->toBeFalse();
+
+ TimerRegistry::run();
+
+ expect($timer->isEnabled())->toBeTrue();
+
+ $timer->disable();
+
+ expect($timer->isEnabled())->toBeFalse();
+
+ $timer->enable();
+
+ expect($timer->isEnabled())->toBeTrue();
+
+ $timer->disable();
+});
+
+it('runs at given using facade', function (): void {
+ $timerExecuted = false;
+
+ $timer = ScheduleFacade::timer(function () use (&$timerExecuted): void {
+ $timerExecuted = true;
+ })->everySecond();
+
+ TimerRegistry::run();
+
+ delay(2);
+
+ expect($timerExecuted)->toBeTrue();
+
+ $timer->disable();
+});
+
+it('sets interval for every two seconds', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyTwoSeconds();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(2.0);
+});
+
+it('sets interval for every five seconds', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyFiveSeconds();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(5.0);
+});
+
+it('sets interval for every ten seconds', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyTenSeconds();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(10.0);
+});
+
+it('sets interval for every fifteen seconds', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyFifteenSeconds();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(15.0);
+});
+
+it('sets interval for every thirty seconds', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyThirtySeconds();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(30.0);
+});
+
+it('sets interval for every minute', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyMinute();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(60.0);
+});
+
+it('sets interval for every two minutes', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyTwoMinutes();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(120.0);
+});
+
+it('sets interval for every five minutes', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyFiveMinutes();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(300.0);
+});
+
+it('sets interval for every ten minutes', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyTenMinutes();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(600.0);
+});
+
+it('sets interval for every fifteen minutes', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyFifteenMinutes();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(900.0);
+});
+
+it('sets interval for every thirty minutes', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->everyThirtyMinutes();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(1800.0);
+});
+
+it('sets interval for hourly', function (): void {
+ $timer = new Timer(function (): void {});
+ $timer->hourly();
+
+ $ref = new ReflectionClass($timer);
+ $prop = $ref->getProperty('interval');
+ $prop->setAccessible(true);
+
+ expect($prop->getValue($timer))->toBe(3600.0);
+});
diff --git a/tests/fixtures/application/config/app.php b/tests/fixtures/application/config/app.php
index f956ea40..616d6d63 100644
--- a/tests/fixtures/application/config/app.php
+++ b/tests/fixtures/application/config/app.php
@@ -64,12 +64,12 @@
],
],
'providers' => [
+ \Phenix\Filesystem\FilesystemServiceProvider::class,
\Phenix\Console\CommandsServiceProvider::class,
\Phenix\Routing\RouteServiceProvider::class,
\Phenix\Database\DatabaseServiceProvider::class,
\Phenix\Redis\RedisServiceProvider::class,
\Phenix\Auth\AuthServiceProvider::class,
- \Phenix\Filesystem\FilesystemServiceProvider::class,
\Phenix\Tasks\TaskServiceProvider::class,
\Phenix\Views\ViewServiceProvider::class,
\Phenix\Cache\CacheServiceProvider::class,
@@ -78,6 +78,7 @@
\Phenix\Queue\QueueServiceProvider::class,
\Phenix\Events\EventServiceProvider::class,
\Phenix\Translation\TranslationServiceProvider::class,
+ \Phenix\Scheduling\SchedulingServiceProvider::class,
\Phenix\Validation\ValidationServiceProvider::class,
],
'response' => [
diff --git a/tests/fixtures/application/events/app.php b/tests/fixtures/application/listen/events.php
similarity index 100%
rename from tests/fixtures/application/events/app.php
rename to tests/fixtures/application/listen/events.php
diff --git a/tests/fixtures/application/schedule/schedules.php b/tests/fixtures/application/schedule/schedules.php
new file mode 100644
index 00000000..174d7fd7
--- /dev/null
+++ b/tests/fixtures/application/schedule/schedules.php
@@ -0,0 +1,3 @@
+