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; + } +}