diff --git a/phpunit.xml b/phpunit.xml
index 3a2fc63..fefddce 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -33,6 +33,8 @@
./tests/HeaderMatchOriginTest.php
./tests/AppOriginAllowedTest.php
./tests/AppCorsMiddlewareTest.php
+ ./tests/CronExpressionTest.php
+ ./tests/ScheduleTest.php
diff --git a/src/CronExpression.php b/src/CronExpression.php
new file mode 100644
index 0000000..f710ba8
--- /dev/null
+++ b/src/CronExpression.php
@@ -0,0 +1,150 @@
+ 0, 'max' => 59], // minute
+ ['min' => 0, 'max' => 23], // hour
+ ['min' => 1, 'max' => 31], // day of month
+ ['min' => 1, 'max' => 12], // month
+ ['min' => 0, 'max' => 7], // day of week (0 and 7 both = Sunday)
+ ];
+
+ /** @var array Expanded valid values per field, in field order. */
+ private array $fields;
+
+ public function __construct(string $expression)
+ {
+ $parts = preg_split('/\s+/', trim($expression));
+
+ if ($parts === false || count($parts) !== 5) {
+ throw new \InvalidArgumentException(
+ "Invalid cron expression '$expression': expected 5 space-separated fields (minute hour day month weekday)."
+ );
+ }
+
+ $this->fields = [];
+ foreach ($parts as $index => $part) {
+ $range = self::FIELD_RANGES[$index];
+ $this->fields[$index] = self::expandField($part, $range['min'], $range['max']);
+ }
+
+ // Day-of-week: normalize 7 to 0 so both mean Sunday.
+ $this->fields[4] = array_values(array_unique(array_map(
+ fn (int $day) => $day === 7 ? 0 : $day,
+ $this->fields[4]
+ )));
+ }
+
+ public function isDue(\DateTimeInterface $at): bool
+ {
+ return in_array((int) $at->format('i'), $this->fields[0], true)
+ && in_array((int) $at->format('G'), $this->fields[1], true)
+ && in_array((int) $at->format('j'), $this->fields[2], true)
+ && in_array((int) $at->format('n'), $this->fields[3], true)
+ && in_array((int) $at->format('w'), $this->fields[4], true);
+ }
+
+ /**
+ * Safety cap on how far ahead nextRunDate() will search (in minutes)
+ * before giving up. Covers every realistic schedule (including yearly
+ * ones) without risking an unbounded loop on a self-contradictory
+ * expression (e.g. day-of-month 31 combined with a month that never
+ * has one).
+ */
+ private const MAX_LOOKAHEAD_MINUTES = 1_053_792; // ~2 years
+
+ /**
+ * Find the next minute at or after $after (default: now) that this
+ * expression is due, minute-resolution, seconds ignored/floored.
+ *
+ * @return \DateTimeImmutable|null Null if nothing matches within the lookahead window.
+ */
+ public function nextRunDate(?\DateTimeInterface $after = null): ?\DateTimeImmutable
+ {
+ $reference = $after !== null
+ ? \DateTimeImmutable::createFromInterface($after)
+ : new \DateTimeImmutable();
+
+ $candidate = $reference
+ ->setTime((int) $reference->format('H'), (int) $reference->format('i'), 0)
+ ->modify('+1 minute');
+
+ for ($i = 0; $i < self::MAX_LOOKAHEAD_MINUTES; $i++) {
+ if ($this->isDue($candidate)) {
+ return $candidate;
+ }
+ $candidate = $candidate->modify('+1 minute');
+ }
+
+ return null;
+ }
+
+ /**
+ * @return int[]
+ */
+ private static function expandField(string $field, int $min, int $max): array
+ {
+ $values = [];
+
+ foreach (explode(',', $field) as $part) {
+ $values = array_merge($values, self::expandPart($part, $min, $max));
+ }
+
+ return array_values(array_unique($values));
+ }
+
+ /**
+ * @return int[]
+ */
+ private static function expandPart(string $part, int $min, int $max): array
+ {
+ $step = 1;
+
+ if (str_contains($part, '/')) {
+ [$part, $stepPart] = explode('/', $part, 2);
+ if (!ctype_digit($stepPart) || (int) $stepPart < 1) {
+ throw new \InvalidArgumentException("Invalid cron step '$stepPart' in field part '$part/$stepPart'.");
+ }
+ $step = (int) $stepPart;
+ }
+
+ if ($part === '*') {
+ [$rangeMin, $rangeMax] = [$min, $max];
+ } elseif (str_contains($part, '-')) {
+ $bounds = explode('-', $part, 2);
+ if (!ctype_digit($bounds[0]) || !ctype_digit($bounds[1])) {
+ throw new \InvalidArgumentException("Invalid cron range '$part'.");
+ }
+ [$rangeMin, $rangeMax] = array_map('intval', $bounds);
+ } elseif (ctype_digit($part)) {
+ $rangeMin = $rangeMax = (int) $part;
+ } else {
+ throw new \InvalidArgumentException("Invalid cron field value '$part'.");
+ }
+
+ if ($rangeMin < $min || $rangeMax > $max || $rangeMin > $rangeMax) {
+ throw new \InvalidArgumentException("Cron field value '$part' is out of range ($min-$max).");
+ }
+
+ $values = [];
+ for ($i = $rangeMin; $i <= $rangeMax; $i += $step) {
+ $values[] = $i;
+ }
+
+ return $values;
+ }
+}
diff --git a/src/Directory.php b/src/Directory.php
index b3c3a10..d6a5ab7 100644
--- a/src/Directory.php
+++ b/src/Directory.php
@@ -629,6 +629,7 @@ public static function initDefaultStructure(): void
'middleware' => 'app/Middleware',
'helpers' => 'app/Helpers',
'services' => 'app/Services',
+ 'schedules' => 'app/Schedules',
// Database directories
'database' => 'database',
@@ -643,6 +644,7 @@ public static function initDefaultStructure(): void
'cache' => 'storage/framework/cache',
'render_views' => 'storage/framework/cache/compiled-views',
'static_views' => 'storage/framework/cache/static-views',
+ 'schedule_locks' => 'storage/framework/schedule-locks',
// Logs and languages
'logs' => 'storage/logs',
diff --git a/src/Schedule.php b/src/Schedule.php
new file mode 100644
index 0000000..de80215
--- /dev/null
+++ b/src/Schedule.php
@@ -0,0 +1,167 @@
+getName() === $name) {
+ return $event;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Clear the registry. Mainly useful for tests and for reloading the
+ * schedule directory from a clean state.
+ */
+ public static function reset(): void
+ {
+ self::$events = [];
+ }
+
+ /**
+ * Require every *.php file under $directory (recursively — so plugins
+ * can namespace their own task file under a subdirectory without
+ * colliding with anyone else's), isolating each file's own load error
+ * so a broken file never prevents the rest of the schedule from
+ * registering.
+ *
+ * @return array Load failures, if any.
+ */
+ public static function loadFromDirectory(string $directory): array
+ {
+ $errors = [];
+
+ if (!is_dir($directory)) {
+ return $errors;
+ }
+
+ $files = array_values(array_filter(
+ File::getFilesRecursive($directory),
+ static fn (string $file): bool => str_ends_with(strtolower($file), '.php')
+ ));
+ sort($files);
+
+ foreach ($files as $file) {
+ try {
+ require $file;
+ } catch (\Throwable $e) {
+ $errors[] = ['file' => $file, 'error' => $e->getMessage()];
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Convenience wrapper loading from the registered 'schedules' directory
+ * (app/Schedules/ by default).
+ *
+ * @return array
+ */
+ public static function loadDefault(): array
+ {
+ $dir = Directory::path('schedules');
+ return $dir === null ? [] : self::loadFromDirectory($dir);
+ }
+
+ /**
+ * Run every registered task that is due at $now (defaults to the
+ * current time). Each task is isolated: a failing or already-running
+ * task is reported but never stops the rest from being attempted.
+ *
+ * @return array
+ */
+ public static function runDue(?\DateTimeInterface $now = null): array
+ {
+ $now = $now ?? new \DateTimeImmutable();
+ $report = [];
+
+ foreach (self::$events as $event) {
+ if ($event->isDue($now)) {
+ $report[] = self::run($event);
+ }
+ }
+
+ return $report;
+ }
+
+ /**
+ * Run a single task immediately, bypassing its own due-check — used by
+ * runDue() for each due task, and directly by tooling like
+ * webrium/console's `schedule:test` to trigger one task on demand.
+ * Isolated the same way as runDue(): a failure is reported, never
+ * thrown, and the task's own overlap lock still applies.
+ *
+ * @return array{name: string, status: 'ran'|'skipped'|'failed', error: string|null}
+ */
+ public static function run(ScheduleEvent $event): array
+ {
+ $name = $event->getName();
+ $lock = new ScheduleLock($event->lockKey());
+
+ if (!$lock->acquire()) {
+ return ['name' => $name, 'status' => 'skipped', 'error' => 'already running'];
+ }
+
+ try {
+ $event->run();
+ return ['name' => $name, 'status' => 'ran', 'error' => null];
+ } catch (\Throwable $e) {
+ Debug::triggerError(
+ "Scheduled task '$name' failed: " . $e->getMessage(),
+ $e->getFile(),
+ $e->getLine(),
+ 500,
+ false,
+ 'ScheduleTaskError'
+ );
+
+ return ['name' => $name, 'status' => 'failed', 'error' => $e->getMessage()];
+ } finally {
+ $lock->release();
+ }
+ }
+}
diff --git a/src/ScheduleEvent.php b/src/ScheduleEvent.php
new file mode 100644
index 0000000..dfe7dc4
--- /dev/null
+++ b/src/ScheduleEvent.php
@@ -0,0 +1,206 @@
+callback = $callback;
+ }
+
+ /**
+ * Assign a stable, human-readable name used for lock files and reports.
+ * Recommended for any task, required for closures if a specific report
+ * label is wanted (a closure otherwise falls back to its file:line).
+ */
+ public function name(string $name): self
+ {
+ $this->name = $name;
+ return $this;
+ }
+
+ public function getName(): string
+ {
+ return $this->name ?? $this->inferName();
+ }
+
+ /**
+ * Filesystem-safe identifier derived from the task name, used for the
+ * per-task lock file so concurrent/overlapping runs are detected.
+ */
+ public function lockKey(): string
+ {
+ $safe = preg_replace('/[^a-zA-Z0-9_.-]/', '_', $this->getName()) ?? '';
+ return $safe === '' || strlen($safe) > 150 ? sha1($this->getName()) : $safe;
+ }
+
+ public function cron(string $expression): self
+ {
+ $this->expression = $expression;
+ $this->cron = null;
+ return $this;
+ }
+
+ public function getExpression(): string
+ {
+ return $this->expression;
+ }
+
+ public function everyMinute(): self
+ {
+ return $this->cron('* * * * *');
+ }
+
+ public function everyFiveMinutes(): self
+ {
+ return $this->cron('*/5 * * * *');
+ }
+
+ public function everyTenMinutes(): self
+ {
+ return $this->cron('*/10 * * * *');
+ }
+
+ public function everyFifteenMinutes(): self
+ {
+ return $this->cron('*/15 * * * *');
+ }
+
+ public function everyThirtyMinutes(): self
+ {
+ return $this->cron('0,30 * * * *');
+ }
+
+ public function hourly(): self
+ {
+ return $this->cron('0 * * * *');
+ }
+
+ public function hourlyAt(int $minute): self
+ {
+ return $this->cron("$minute * * * *");
+ }
+
+ public function daily(): self
+ {
+ return $this->cron('0 0 * * *');
+ }
+
+ public function dailyAt(string $time): self
+ {
+ [$hour, $minute] = self::parseTime($time);
+ return $this->cron("$minute $hour * * *");
+ }
+
+ public function weekly(): self
+ {
+ return $this->cron('0 0 * * 0');
+ }
+
+ public function weeklyOn(int $dayOfWeek, string $time = '00:00'): self
+ {
+ [$hour, $minute] = self::parseTime($time);
+ return $this->cron("$minute $hour * * $dayOfWeek");
+ }
+
+ public function monthly(): self
+ {
+ return $this->cron('0 0 1 * *');
+ }
+
+ public function isDue(\DateTimeInterface $at): bool
+ {
+ return $this->resolvedCron()->isDue($at);
+ }
+
+ /**
+ * @return \DateTimeImmutable|null Null if nothing matches within the lookahead window (see CronExpression).
+ */
+ public function nextRunDate(?\DateTimeInterface $after = null): ?\DateTimeImmutable
+ {
+ return $this->resolvedCron()->nextRunDate($after);
+ }
+
+ /**
+ * Invoke the task's callback. Throws on failure — callers decide how to
+ * isolate/report that (see Schedule::runDue()).
+ */
+ public function run(): mixed
+ {
+ return self::invoke($this->callback);
+ }
+
+ private function resolvedCron(): CronExpression
+ {
+ return $this->cron ??= new CronExpression($this->expression);
+ }
+
+ /**
+ * @return array{0: int, 1: int} [hour, minute]
+ */
+ private static function parseTime(string $time): array
+ {
+ $parts = array_pad(explode(':', $time), 2, '0');
+ return [(int) $parts[0], (int) $parts[1]];
+ }
+
+ /**
+ * @param callable|string|array $callback
+ */
+ private static function invoke($callback): mixed
+ {
+ if (is_string($callback) && str_contains($callback, '@')) {
+ [$class, $method] = explode('@', $callback, 2);
+
+ if (!class_exists($class)) {
+ throw new \RuntimeException("Scheduled task class '$class' not found.");
+ }
+
+ $callback = [new $class(), $method];
+ }
+
+ if (!is_callable($callback)) {
+ throw new \RuntimeException('Scheduled task callback is not callable.');
+ }
+
+ return call_user_func($callback);
+ }
+
+ private function inferName(): string
+ {
+ if (is_string($this->callback)) {
+ return $this->callback;
+ }
+
+ if (is_array($this->callback)) {
+ [$target, $method] = $this->callback;
+ $class = is_object($target) ? get_class($target) : $target;
+ return "$class::$method";
+ }
+
+ if ($this->callback instanceof \Closure) {
+ $ref = new \ReflectionFunction($this->callback);
+ return 'closure@' . $ref->getFileName() . ':' . $ref->getStartLine();
+ }
+
+ return 'task@' . spl_object_id($this);
+ }
+}
diff --git a/src/ScheduleLock.php b/src/ScheduleLock.php
new file mode 100644
index 0000000..3e8d4c2
--- /dev/null
+++ b/src/ScheduleLock.php
@@ -0,0 +1,69 @@
+path = rtrim($dir, '/\\') . '/' . $key . '.lock';
+ }
+
+ /**
+ * Try to acquire the lock without blocking.
+ *
+ * @return bool True if acquired, false if another process already holds it.
+ */
+ public function acquire(): bool
+ {
+ $dir = dirname($this->path);
+ if (!is_dir($dir)) {
+ @mkdir($dir, 0755, true);
+ }
+
+ $handle = @fopen($this->path, 'c');
+ if ($handle === false) {
+ return false;
+ }
+
+ if (!flock($handle, LOCK_EX | LOCK_NB)) {
+ fclose($handle);
+ return false;
+ }
+
+ $this->handle = $handle;
+ return true;
+ }
+
+ public function release(): void
+ {
+ if ($this->handle !== null) {
+ flock($this->handle, LOCK_UN);
+ fclose($this->handle);
+ $this->handle = null;
+ }
+ }
+}
diff --git a/tests/CronExpressionTest.php b/tests/CronExpressionTest.php
new file mode 100644
index 0000000..0dfd41c
--- /dev/null
+++ b/tests/CronExpressionTest.php
@@ -0,0 +1,175 @@
+assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-06-15 23:59:00')));
+ }
+
+ public function testFixedMinuteOnlyMatchesThatMinute(): void
+ {
+ $cron = new CronExpression('30 * * * *');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 10:30:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 10:31:00')));
+ }
+
+ public function testStepMatchesEveryNMinutes(): void
+ {
+ $cron = new CronExpression('*/5 * * * *');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:05:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:10:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:07:00')));
+ }
+
+ public function testRangeMatchesWithinBounds(): void
+ {
+ $cron = new CronExpression('0 9-17 * * *');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 09:00:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 17:00:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 08:00:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 18:00:00')));
+ }
+
+ public function testSteppedRangeMatchesEveryNWithinBounds(): void
+ {
+ $cron = new CronExpression('0-30/10 * * * *');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:00:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:10:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:20:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:30:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:40:00')));
+ }
+
+ public function testListMatchesAnyListedValue(): void
+ {
+ $cron = new CronExpression('0,15,30,45 * * * *');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:15:00')));
+ $this->assertTrue($cron->isDue($this->dt('2024-01-01 00:45:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-01 00:20:00')));
+ }
+
+ public function testAllFieldsMustMatchSimultaneously(): void
+ {
+ $cron = new CronExpression('30 14 1 6 *');
+ $this->assertTrue($cron->isDue($this->dt('2024-06-01 14:30:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-06-01 14:31:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-06-02 14:30:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-07-01 14:30:00')));
+ }
+
+ public function testDayOfWeekZeroMeansSunday(): void
+ {
+ $cron = new CronExpression('0 0 * * 0');
+ // 2024-01-07 is a Sunday.
+ $this->assertTrue($cron->isDue($this->dt('2024-01-07 00:00:00')));
+ $this->assertFalse($cron->isDue($this->dt('2024-01-08 00:00:00')));
+ }
+
+ public function testDayOfWeekSevenAlsoMeansSunday(): void
+ {
+ $cron = new CronExpression('0 0 * * 7');
+ $this->assertTrue($cron->isDue($this->dt('2024-01-07 00:00:00')));
+ }
+
+ public function testThrowsOnWrongFieldCount(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new CronExpression('* * *');
+ }
+
+ public function testThrowsOnOutOfRangeValue(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new CronExpression('60 * * * *');
+ }
+
+ public function testThrowsOnInvertedRange(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new CronExpression('30-10 * * * *');
+ }
+
+ public function testThrowsOnNonNumericValue(): void
+ {
+ $this->expectException(\InvalidArgumentException::class);
+ new CronExpression('abc * * * *');
+ }
+
+ // =========================================================================
+ // nextRunDate()
+ // =========================================================================
+
+ public function testNextRunDateForEveryMinuteIsOneMinuteAhead(): void
+ {
+ $cron = new CronExpression('* * * * *');
+ $next = $cron->nextRunDate($this->dt('2024-01-01 10:00:30'));
+ $this->assertSame('2024-01-01 10:01:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ public function testNextRunDateSkipsToNextMatchingStep(): void
+ {
+ $cron = new CronExpression('*/15 * * * *');
+ $next = $cron->nextRunDate($this->dt('2024-01-01 10:01:00'));
+ $this->assertSame('2024-01-01 10:15:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ public function testNextRunDateCrossesDayBoundary(): void
+ {
+ $cron = new CronExpression('0 0 * * *');
+ $next = $cron->nextRunDate($this->dt('2024-01-01 23:59:00'));
+ $this->assertSame('2024-01-02 00:00:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ public function testNextRunDateCrossesMonthBoundaryForMonthlySchedule(): void
+ {
+ $cron = new CronExpression('0 0 1 * *');
+ $next = $cron->nextRunDate($this->dt('2024-01-15 12:00:00'));
+ $this->assertSame('2024-02-01 00:00:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ /**
+ * A candidate is never due at the reference instant itself, even when
+ * it exactly matches — nextRunDate() always looks strictly forward.
+ */
+ public function testNextRunDateNeverReturnsTheReferenceInstantItself(): void
+ {
+ $cron = new CronExpression('0 0 * * *');
+ $next = $cron->nextRunDate($this->dt('2024-01-02 00:00:00'));
+ $this->assertSame('2024-01-03 00:00:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ public function testNextRunDateDefaultsToNowWhenNoReferenceGiven(): void
+ {
+ $cron = new CronExpression('* * * * *');
+ $next = $cron->nextRunDate();
+ $this->assertGreaterThan(new \DateTimeImmutable(), $next);
+ }
+}
diff --git a/tests/ScheduleTest.php b/tests/ScheduleTest.php
new file mode 100644
index 0000000..2c17724
--- /dev/null
+++ b/tests/ScheduleTest.php
@@ -0,0 +1,332 @@
+root = sys_get_temp_dir() . '/webrium_schedule_test_' . uniqid();
+ mkdir($this->root, 0755, true);
+ App::setRootPath($this->root);
+ Directory::initDefaultStructure();
+ Schedule::reset();
+ }
+
+ protected function tearDown(): void
+ {
+ Schedule::reset();
+ $this->removeDir($this->root);
+ }
+
+ private function removeDir(string $dir): void
+ {
+ if (!is_dir($dir)) {
+ return;
+ }
+ foreach (scandir($dir) as $item) {
+ if ($item === '.' || $item === '..') {
+ continue;
+ }
+ $path = "$dir/$item";
+ is_dir($path) ? $this->removeDir($path) : unlink($path);
+ }
+ rmdir($dir);
+ }
+
+ // =========================================================================
+ // 1. ScheduleEvent fluent interval helpers
+ // =========================================================================
+
+ public function testEveryMinute(): void
+ {
+ $this->assertSame('* * * * *', (new ScheduleEvent(fn () => null))->everyMinute()->getExpression());
+ }
+
+ public function testEveryFiveMinutes(): void
+ {
+ $this->assertSame('*/5 * * * *', (new ScheduleEvent(fn () => null))->everyFiveMinutes()->getExpression());
+ }
+
+ public function testHourlyAt(): void
+ {
+ $this->assertSame('15 * * * *', (new ScheduleEvent(fn () => null))->hourlyAt(15)->getExpression());
+ }
+
+ public function testDailyAtParsesHourAndMinute(): void
+ {
+ $this->assertSame('30 13 * * *', (new ScheduleEvent(fn () => null))->dailyAt('13:30')->getExpression());
+ }
+
+ public function testDailyAtDefaultsMinuteWhenOmitted(): void
+ {
+ $this->assertSame('0 9 * * *', (new ScheduleEvent(fn () => null))->dailyAt('9')->getExpression());
+ }
+
+ public function testWeeklyOnSpecificDay(): void
+ {
+ $this->assertSame('0 8 * * 3', (new ScheduleEvent(fn () => null))->weeklyOn(3, '08:00')->getExpression());
+ }
+
+ public function testMonthly(): void
+ {
+ $this->assertSame('0 0 1 * *', (new ScheduleEvent(fn () => null))->monthly()->getExpression());
+ }
+
+ public function testExplicitCronExpressionOverridesFluentHelpers(): void
+ {
+ $event = (new ScheduleEvent(fn () => null))->everyMinute()->cron('0 3 * * *');
+ $this->assertSame('0 3 * * *', $event->getExpression());
+ }
+
+ public function testNextRunDateDelegatesToTheUnderlyingCronExpression(): void
+ {
+ $event = (new ScheduleEvent(fn () => null))->dailyAt('03:00');
+ $next = $event->nextRunDate(new \DateTimeImmutable('2024-01-01 10:00:00'));
+ $this->assertSame('2024-01-02 03:00:00', $next->format('Y-m-d H:i:s'));
+ }
+
+ // =========================================================================
+ // 2. name() / lockKey()
+ // =========================================================================
+
+ public function testExplicitNameIsReturnedVerbatim(): void
+ {
+ $event = (new ScheduleEvent(fn () => null))->name('email.send-queued');
+ $this->assertSame('email.send-queued', $event->getName());
+ }
+
+ public function testStringCallbackNameIsInferredFromCallbackItself(): void
+ {
+ $event = new ScheduleEvent('App\\Services\\Reports@daily');
+ $this->assertSame('App\\Services\\Reports@daily', $event->getName());
+ }
+
+ public function testArrayCallbackNameIsInferredFromClassAndMethod(): void
+ {
+ $event = new ScheduleEvent([\Tests\ScheduleTestTarget::class, 'ok']);
+ $this->assertSame(\Tests\ScheduleTestTarget::class . '::ok', $event->getName());
+ }
+
+ public function testClosureNameIsStableAcrossCallsForTheSameInstance(): void
+ {
+ $event = new ScheduleEvent(fn () => null);
+ $this->assertSame($event->getName(), $event->getName());
+ }
+
+ public function testLockKeyIsFilesystemSafe(): void
+ {
+ $event = (new ScheduleEvent(fn () => null))->name('weird name / with * chars');
+ $this->assertMatchesRegularExpression('/^[a-zA-Z0-9_.-]+$/', $event->lockKey());
+ }
+
+ // =========================================================================
+ // 3. Schedule registry
+ // =========================================================================
+
+ public function testCallRegistersEventAndReturnsIt(): void
+ {
+ $event = Schedule::call(fn () => null);
+ $this->assertInstanceOf(ScheduleEvent::class, $event);
+ $this->assertSame([$event], Schedule::all());
+ }
+
+ public function testResetClearsRegistry(): void
+ {
+ Schedule::call(fn () => null);
+ Schedule::reset();
+ $this->assertSame([], Schedule::all());
+ }
+
+ public function testFindReturnsRegisteredEventByName(): void
+ {
+ $event = Schedule::call(fn () => null)->name('reports.daily');
+ $this->assertSame($event, Schedule::find('reports.daily'));
+ }
+
+ public function testFindReturnsNullForUnknownName(): void
+ {
+ $this->assertNull(Schedule::find('does.not.exist'));
+ }
+
+ /**
+ * Schedule::run() executes a task immediately regardless of whether it
+ * is actually due — the mechanism behind an on-demand `schedule:test`
+ * command — while still going through the same lock/error isolation
+ * as runDue().
+ */
+ public function testRunExecutesATaskImmediatelyIgnoringItsDueCheck(): void
+ {
+ $ran = false;
+ // Scheduled for 03:00 daily; "now" is irrelevant to Schedule::run().
+ $event = Schedule::call(function () use (&$ran) { $ran = true; })
+ ->name('manual-trigger')->dailyAt('03:00');
+
+ $result = Schedule::run($event);
+
+ $this->assertTrue($ran);
+ $this->assertSame(['name' => 'manual-trigger', 'status' => 'ran', 'error' => null], $result);
+ }
+
+ // =========================================================================
+ // 4. loadFromDirectory(): discovery + per-file error isolation
+ // =========================================================================
+
+ public function testLoadFromDirectoryRegistersTasksFromEveryFile(): void
+ {
+ $dir = $this->root . '/Schedules';
+ mkdir($dir, 0755, true);
+ file_put_contents($dir . '/One.php', ' null)->name("one");');
+ file_put_contents($dir . '/Two.php', ' null)->name("two");');
+
+ $errors = Schedule::loadFromDirectory($dir);
+
+ $this->assertSame([], $errors);
+ $this->assertCount(2, Schedule::all());
+ }
+
+ public function testLoadFromDirectoryIsRecursiveForPluginSubfolders(): void
+ {
+ $dir = $this->root . '/Schedules';
+ mkdir($dir . '/some-plugin', 0755, true);
+ file_put_contents($dir . '/some-plugin/Task.php', ' null);');
+
+ Schedule::loadFromDirectory($dir);
+
+ $this->assertCount(1, Schedule::all());
+ }
+
+ public function testLoadFromDirectoryReturnsEmptyArrayForMissingDirectory(): void
+ {
+ $this->assertSame([], Schedule::loadFromDirectory($this->root . '/does-not-exist'));
+ }
+
+ /**
+ * A broken task file (throws while being registered) must not prevent a
+ * different, valid file from registering its own task.
+ */
+ public function testABrokenFileDoesNotPreventOtherFilesFromLoading(): void
+ {
+ $dir = $this->root . '/Schedules';
+ mkdir($dir, 0755, true);
+ file_put_contents($dir . '/Broken.php', ' null)->name("good");');
+
+ $errors = Schedule::loadFromDirectory($dir);
+
+ $this->assertCount(1, $errors);
+ $this->assertStringContainsString('Broken.php', $errors[0]['file']);
+ $this->assertStringContainsString('boom while loading', $errors[0]['error']);
+
+ $this->assertCount(1, Schedule::all());
+ $this->assertSame('good', Schedule::all()[0]->getName());
+ }
+
+ // =========================================================================
+ // 5. runDue(): due-filtering + per-task failure isolation + overlap lock
+ // =========================================================================
+
+ public function testRunDueOnlyRunsTasksThatAreDueAtGivenTime(): void
+ {
+ $ranFive = false;
+ $ranHourly = false;
+
+ Schedule::call(function () use (&$ranFive) { $ranFive = true; })->name('five')->everyFiveMinutes();
+ Schedule::call(function () use (&$ranHourly) { $ranHourly = true; })->name('hourly')->hourlyAt(0);
+
+ Schedule::runDue(new \DateTimeImmutable('2024-01-01 10:05:00'));
+
+ $this->assertTrue($ranFive);
+ $this->assertFalse($ranHourly);
+ }
+
+ public function testRunDueReportsRanStatusForSuccessfulTask(): void
+ {
+ Schedule::call(fn () => null)->name('ok-task')->everyMinute();
+
+ $report = Schedule::runDue(new \DateTimeImmutable('2024-01-01 00:00:00'));
+
+ $this->assertSame([['name' => 'ok-task', 'status' => 'ran', 'error' => null]], $report);
+ }
+
+ /**
+ * SECURITY/RELIABILITY: one task throwing must not stop other due tasks
+ * from running, and must be reported as 'failed', not silently swallowed.
+ */
+ public function testAFailingTaskDoesNotPreventOtherDueTasksFromRunning(): void
+ {
+ $secondRan = false;
+
+ Schedule::call(function () { throw new \RuntimeException('task blew up'); })
+ ->name('failing')->everyMinute();
+ Schedule::call(function () use (&$secondRan) { $secondRan = true; })
+ ->name('second')->everyMinute();
+
+ $report = Schedule::runDue(new \DateTimeImmutable('2024-01-01 00:00:00'));
+
+ $this->assertTrue($secondRan, 'a later due task must still run after an earlier one fails');
+
+ $byName = array_column($report, null, 'name');
+ $this->assertSame('failed', $byName['failing']['status']);
+ $this->assertStringContainsString('task blew up', $byName['failing']['error']);
+ $this->assertSame('ran', $byName['second']['status']);
+ }
+
+ /**
+ * Simulates "another process is still running this task" by holding the
+ * task's lock open from the test itself before calling runDue() — a real
+ * overlap can't be reproduced deterministically in a single-threaded
+ * test process, but the lock primitive is exactly what a slow-running
+ * task would leave held in production.
+ */
+ public function testOverlappingRunIsSkippedWhileLockIsHeld(): void
+ {
+ $ran = false;
+ $event = Schedule::call(function () use (&$ran) { $ran = true; })
+ ->name('held-task')->everyMinute();
+
+ $externalLock = new \Webrium\ScheduleLock($event->lockKey());
+ $this->assertTrue($externalLock->acquire());
+
+ $now = new \DateTimeImmutable('2024-01-01 00:00:00');
+ $report = Schedule::runDue($now);
+
+ $this->assertFalse($ran, 'task must not run while its lock is already held elsewhere');
+ $this->assertSame('skipped', $report[0]['status']);
+
+ $externalLock->release();
+
+ $report = Schedule::runDue($now);
+
+ $this->assertTrue($ran, 'task must run once the lock is released');
+ $this->assertSame('ran', $report[0]['status']);
+ }
+}
+
+class ScheduleTestTarget
+{
+ public function ok(): bool
+ {
+ return true;
+ }
+}