diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dfc6956..7e260f81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rector as dev dependency for automated code refactoring - Additional PHP extensions required in CI: `bcmath`, `gd`, `zip` - PHPUnit strict testing flags: `--fail-on-warning`, `--fail-on-risky` +- **Cron Scheduler**: New CLI command `php qt cron:run` for running scheduled tasks + - Task definition via PHP files in `cron/` directory + - Cron expression parsing using `dragonmantank/cron-expression` library + - File-based task locking to prevent concurrent execution + - Comprehensive logging of task execution and errors + - Support for force mode and specific task execution + - Automatic cleanup of stale locks (older than 24 hours) + - Full documentation in `docs/cron-scheduler.md` ### Removed - Support for PHP 7.3 and earlier versions diff --git a/composer.json b/composer.json index 2cf4e4be..9eaef8f0 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,8 @@ "league/commonmark": "^2.0", "ezyang/htmlpurifier": "^4.18", "povils/figlet": "^0.1.0", - "ramsey/uuid": "^4.2" + "ramsey/uuid": "^4.2", + "dragonmantank/cron-expression": "^3.0" }, "require-dev": { "phpunit/phpunit": "^9.0", diff --git a/src/Console/Commands/CronRunCommand.php b/src/Console/Commands/CronRunCommand.php new file mode 100644 index 00000000..43d7693c --- /dev/null +++ b/src/Console/Commands/CronRunCommand.php @@ -0,0 +1,140 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Console\Commands; + +use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Libraries\Cron\CronManager; +use Quantum\Console\QtCommand; + +/** + * Class CronRunCommand + * @package Quantum\Console + */ +class CronRunCommand extends QtCommand +{ + /** + * The console command name. + * @var string + */ + protected $name = 'cron:run'; + + /** + * The console command description. + * @var string + */ + protected $description = 'Run scheduled cron tasks'; + + /** + * Command help text. + * @var string + */ + protected $help = 'Executes scheduled tasks defined in the cron directory. Use --task to run a single task or --force to bypass locks.'; + + /** + * Command options + * @var array> + */ + protected $options = [ + ['force', 'f', 'none', 'Force run tasks ignoring locks'], + ['task', 't', 'optional', 'Run a specific task by name'], + ['path', 'p', 'optional', 'Custom cron directory path'], + ]; + + /** + * Executes the command + */ + public function exec() + { + $force = (bool) $this->getOption('force'); + $taskName = $this->getOption('task'); + $cronPath = $this->getOption('path') ?: cron_config('path'); + + try { + $manager = new CronManager($cronPath); + + if ($taskName) { + $this->runSpecificTask($manager, $taskName, $force); + } else { + $this->runAllDueTasks($manager, $force); + } + } catch (CronException $e) { + $this->error($e->getMessage()); + } catch (\Throwable $e) { + $this->error('Unexpected error: ' . $e->getMessage()); + } + } + + /** + * Run all due tasks + * @param CronManager $manager + * @param bool $force + * @return void + */ + private function runAllDueTasks(CronManager $manager, bool $force): void + { + $this->info('Running scheduled tasks...'); + + $stats = $manager->runDueTasks($force); + + $this->output(''); + $this->info('Execution Summary:'); + $this->output(" Total tasks: {$stats['total']}"); + $this->output(" Executed: {$stats['executed']}"); + $this->output(" Skipped: {$stats['skipped']}"); + + if ($stats['locked'] > 0) { + $this->output(" Locked: {$stats['locked']}"); + } + + if ($stats['failed'] > 0) { + $this->output(" Failed: {$stats['failed']}"); + } + + $this->output(''); + + if ($stats['executed'] > 0) { + $this->info('✓ Tasks completed successfully'); + } elseif ($stats['total'] === 0) { + $this->comment('No tasks found in cron directory'); + } else { + $this->comment('No tasks were due to run'); + } + } + + /** + * Run a specific task + * @param CronManager $manager + * @param string $taskName + * @param bool $force + * @return void + * @throws CronException + */ + private function runSpecificTask(CronManager $manager, string $taskName, bool $force): void + { + $this->info("Running task: {$taskName}"); + + $manager->runTaskByName($taskName, $force); + + $stats = $manager->getStats(); + + if ($stats['executed'] > 0) { + $this->info("✓ Task '{$taskName}' completed successfully"); + } elseif ($stats['failed'] > 0) { + $this->error("✗ Task '{$taskName}' failed"); + } elseif ($stats['locked'] > 0) { + $this->comment("⚠ Task '{$taskName}' is locked"); + } + } +} diff --git a/src/Libraries/Cron/Contracts/CronTaskInterface.php b/src/Libraries/Cron/Contracts/CronTaskInterface.php new file mode 100644 index 00000000..90c991cc --- /dev/null +++ b/src/Libraries/Cron/Contracts/CronTaskInterface.php @@ -0,0 +1,46 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Contracts; + +/** + * Interface CronTaskInterface + * @package Quantum\Libraries\Cron + */ +interface CronTaskInterface +{ + /** + * Get the cron expression + * @return string + */ + public function getExpression(): string; + + /** + * Get the task name + * @return string + */ + public function getName(): string; + + /** + * Check if the task should run at the current time + * @return bool + */ + public function shouldRun(): bool; + + /** + * Execute the task + * @return void + */ + public function handle(): void; +} diff --git a/src/Libraries/Cron/CronLock.php b/src/Libraries/Cron/CronLock.php new file mode 100644 index 00000000..e5b0d68d --- /dev/null +++ b/src/Libraries/Cron/CronLock.php @@ -0,0 +1,252 @@ +get) + * - isLocked() is non-destructive (does NOT delete files); cleanup handles stale deletion + * - optional refresh() to update timestamp while running + */ +class CronLock +{ + private string $lockDirectory; + private string $taskName; + private string $lockFile; + + /** @var resource|null */ + private $lockHandle = null; + + private bool $ownsLock = false; + private int $maxLockAge; + + private const DEFAULT_MAX_LOCK_AGE = 86400; + + public function __construct(string $taskName, ?string $lockDirectory = null, ?int $maxLockAge = null) + { + $this->taskName = $this->sanitizeTaskName($taskName); + $this->lockDirectory = $this->resolveLockDirectory($lockDirectory); + $this->lockFile = $this->lockDirectory . DS . $this->taskName . '.lock'; + $this->maxLockAge = $maxLockAge ?? (int) cron_config('max_lock_age', self::DEFAULT_MAX_LOCK_AGE); + + $this->ensureLockDirectoryExists(); + $this->cleanupStaleLocks(); + } + + public function acquire(): bool + { + $this->lockHandle = fopen($this->lockFile, 'c+'); + if ($this->lockHandle === false) { + $this->lockHandle = null; + $this->ownsLock = false; + return false; + } + + if (!flock($this->lockHandle, LOCK_EX | LOCK_NB)) { + fclose($this->lockHandle); + $this->lockHandle = null; + $this->ownsLock = false; + return false; + } + + if (!$this->writeTimestampToHandle($this->lockHandle)) { + flock($this->lockHandle, LOCK_UN); + fclose($this->lockHandle); + $this->lockHandle = null; + $this->ownsLock = false; + return false; + } + $this->ownsLock = true; + + return true; + } + + /** + * Update lock timestamp (useful for long-running jobs) + */ + public function refresh(): bool + { + if (!$this->ownsLock || $this->lockHandle === null) { + return false; + } + + return $this->writeTimestampToHandle($this->lockHandle); + } + + public function release(): bool + { + if (!$this->ownsLock || $this->lockHandle === null) { + return true; + } + + $unlocked = flock($this->lockHandle, LOCK_UN); + $closed = fclose($this->lockHandle); + + $this->lockHandle = null; + $this->ownsLock = false; + + $removed = true; + if (fs()->exists($this->lockFile)) { + $removed = fs()->remove($this->lockFile); + } + + return $unlocked && $closed && $removed; + } + + /** + * Check if another process currently holds the lock. + */ + public function isLocked(): bool + { + if (!fs()->exists($this->lockFile)) { + return false; + } + + $handle = fopen($this->lockFile, 'c+'); + if ($handle === false) { + return true; + } + + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + return true; + } + + flock($handle, LOCK_UN); + fclose($handle); + + return false; + } + + private function sanitizeTaskName(string $taskName): string + { + $taskName = trim($taskName); + if ($taskName === '') { + return 'default'; + } + + // Keep safe filename chars only + $taskName = preg_replace('/[^a-zA-Z0-9._-]+/', '_', $taskName) ?? 'default'; + $taskName = trim($taskName, '._-'); + + return $taskName !== '' ? $taskName : 'default'; + } + + private function resolveLockDirectory(?string $lockDirectory): string + { + $path = $lockDirectory ?? cron_config('lock_path'); + return $path === null ? $this->getDefaultLockDirectory() : $path; + } + + private function getDefaultLockDirectory(): string + { + return base_dir() . DS . 'runtime' . DS . 'cron' . DS . 'locks'; + } + + private function ensureLockDirectoryExists(): void + { + if ($this->lockDirectory === '') { + throw CronException::lockDirectoryNotWritable(''); + } + + $this->createDirectory($this->lockDirectory); + + if (!fs()->isWritable($this->lockDirectory)) { + throw CronException::lockDirectoryNotWritable($this->lockDirectory); + } + } + + private function createDirectory(string $directory): void + { + if (fs()->isDirectory($directory)) { + return; + } + + $parent = dirname($directory); + if ($parent && $parent !== $directory) { + $this->createDirectory($parent); + } + + // @phpstan-ignore-next-line + if (!fs()->makeDirectory($directory) && !fs()->isDirectory($directory)) { + throw CronException::lockDirectoryNotWritable($directory); + } + } + + /** + * Removes stale lock files that are NOT currently locked by any process. + * Safe because we take LOCK_EX before removing. + */ + private function cleanupStaleLocks(): void + { + if (!fs()->isDirectory($this->lockDirectory)) { + return; + } + + $files = fs()->glob($this->lockDirectory . DS . '*.lock') ?: []; + $now = time(); + + foreach ($files as $file) { + $handle = fopen($file, 'c+'); + if ($handle === false) { + continue; + } + + // If someone holds it, skip + if (!flock($handle, LOCK_EX | LOCK_NB)) { + fclose($handle); + continue; + } + + $timestamp = $this->readTimestampFromHandle($handle); + + if ($timestamp !== null && ($now - $timestamp) > $this->maxLockAge) { + flock($handle, LOCK_UN); + fclose($handle); + fs()->remove($file); + continue; + } + + flock($handle, LOCK_UN); + fclose($handle); + } + } + + private function writeTimestampToHandle($handle): bool + { + if (ftruncate($handle, 0) === false) { + return false; + } + if (rewind($handle) === false) { + return false; + } + if (fwrite($handle, (string) time()) === false) { + return false; + } + if (fflush($handle) === false) { + return false; + } + + return true; + } + + private function readTimestampFromHandle($handle): ?int + { + if (rewind($handle) === false) { + return null; + } + $content = stream_get_contents($handle); + if ($content === false) { + return null; + } + + $timestamp = (int) trim((string) $content); + return $timestamp > 0 ? $timestamp : null; + } +} diff --git a/src/Libraries/Cron/CronManager.php b/src/Libraries/Cron/CronManager.php new file mode 100644 index 00000000..b5abe5ba --- /dev/null +++ b/src/Libraries/Cron/CronManager.php @@ -0,0 +1,244 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Contracts\CronTaskInterface; +use Quantum\Libraries\Logger\Factories\LoggerFactory; +use Quantum\Libraries\Cron\Exceptions\CronException; +use Quantum\Libraries\Logger\Logger; + +/** + * Class CronManager + * @package Quantum\Libraries\Cron + */ +class CronManager +{ + /** + * Loaded tasks + * @var array + */ + private $tasks = []; + + /** + * Cron directory path + * @var string + */ + private $cronDirectory; + + /** + * Execution statistics + * @var array + */ + private $stats = [ + 'total' => 0, + 'executed' => 0, + 'skipped' => 0, + 'failed' => 0, + 'locked' => 0, + ]; + + /** + * CronManager constructor + * @param string|null $cronDirectory + */ + public function __construct(?string $cronDirectory = null) + { + $configuredPath = $cronDirectory ?? cron_config('path'); + $this->cronDirectory = $configuredPath ?: $this->getDefaultCronDirectory(); + } + + /** + * Load tasks from cron directory + * @return void + * @throws CronException + */ + public function loadTasks(): void + { + if (!fs()->isDirectory($this->cronDirectory)) { + if ($this->cronDirectory !== $this->getDefaultCronDirectory()) { + throw CronException::cronDirectoryNotFound($this->cronDirectory); + } + return; + } + + $files = fs()->glob($this->cronDirectory . DS . '*.php') ?: []; + + foreach ($files as $file) { + $this->loadTaskFromFile($file); + } + + $this->stats['total'] = count($this->tasks); + } + + /** + * Load a single task from file + * @param string $file + * @return void + * @throws CronException + */ + private function loadTaskFromFile(string $file): void + { + $task = fs()->require($file); + + if (is_array($task)) { + $task = $this->createTaskFromArray($task); + } + + if (!$task instanceof CronTaskInterface) { + throw CronException::invalidTaskFile($file); + } + + $this->tasks[$task->getName()] = $task; + } + + /** + * Create task from array definition + * @param array $definition + * @return CronTask + * @throws CronException + */ + private function createTaskFromArray(array $definition): CronTask + { + if (!isset($definition['name'], $definition['expression'], $definition['callback'])) { + throw new CronException('Task definition must contain name, expression, and callback'); + } + + return new CronTask( + $definition['name'], + $definition['expression'], + $definition['callback'] + ); + } + + /** + * Run all due tasks + * @param bool $force Ignore locks + * @return array Statistics + */ + public function runDueTasks(bool $force = false): array + { + $this->loadTasks(); + + foreach ($this->tasks as $task) { + if ($task->shouldRun()) { + $this->runTask($task, $force); + } else { + $this->stats['skipped']++; + } + } + + return $this->stats; + } + + /** + * Run a specific task by name + * @param string $taskName + * @param bool $force Ignore locks + * @return void + * @throws CronException + */ + public function runTaskByName(string $taskName, bool $force = false): void + { + $this->loadTasks(); + + if (!isset($this->tasks[$taskName])) { + throw CronException::taskNotFound($taskName); + } + + $this->runTask($this->tasks[$taskName], $force); + } + + /** + * Run a single task + * @param CronTaskInterface $task + * @param bool $force Ignore locks + * @return void + */ + private function runTask(CronTaskInterface $task, bool $force = false): void + { + $lock = new CronLock($task->getName()); + + if (!$force && !$lock->acquire()) { + $this->stats['locked']++; + $this->log('warning', "Task \"{$task->getName()}\" skipped: locked"); + return; + } + + $startTime = microtime(true); + $this->log('info', "Task \"{$task->getName()}\" started"); + + try { + $task->handle(); + $duration = round(microtime(true) - $startTime, 2); + $this->stats['executed']++; + $this->log('info', "Task \"{$task->getName()}\" completed in {$duration}s"); + } catch (\Throwable $e) { + $this->stats['failed']++; + $this->log('error', "Task \"{$task->getName()}\" failed: " . $e->getMessage(), [ + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + } finally { + if (!$force) { + $lock->release(); + } + } + } + + /** + * Get all loaded tasks + * @return array + */ + public function getTasks(): array + { + return $this->tasks; + } + + /** + * Get execution statistics + * @return array + */ + public function getStats(): array + { + return $this->stats; + } + + /** + * Get default cron directory + * @return string + */ + private function getDefaultCronDirectory(): string + { + return base_dir() . DS . 'cron'; + } + + /** + * Log a message + * @param string $level + * @param string $message + * @param array $context + * @return void + */ + private function log(string $level, string $message, array $context = []): void + { + try { + $logger = LoggerFactory::get(Logger::SINGLE); + $logger->log($level, '[CRON] ' . $message, $context); + } catch (\Throwable $exception) { + error_log(sprintf('[CRON] [%s] %s', strtoupper($level), $message)); + } + } +} diff --git a/src/Libraries/Cron/CronTask.php b/src/Libraries/Cron/CronTask.php new file mode 100644 index 00000000..8cf99711 --- /dev/null +++ b/src/Libraries/Cron/CronTask.php @@ -0,0 +1,113 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Contracts\CronTaskInterface; +use Quantum\Libraries\Cron\Exceptions\CronException; +use Cron\CronExpression; + +/** + * Class CronTask + * @package Quantum\Libraries\Cron + */ +class CronTask implements CronTaskInterface +{ + /** + * Cron expression instance + * @var CronExpression + */ + private $cronExpression; + + /** + * Task name + * @var string + */ + private $name; + + /** + * Task callback + * @var callable + */ + private $callback; + + /** + * CronTask constructor + * @param string $name + * @param string $expression + * @param callable $callback + * @throws CronException + */ + public function __construct(string $name, string $expression, callable $callback) + { + $this->name = $name; + $this->callback = $callback; + + try { + $this->cronExpression = new CronExpression($expression); + } catch (\Exception $e) { + throw CronException::invalidExpression($expression); + } + } + + /** + * @inheritDoc + */ + public function getExpression(): string + { + return $this->cronExpression->getExpression(); + } + + /** + * @inheritDoc + */ + public function getName(): string + { + return $this->name; + } + + /** + * @inheritDoc + */ + public function shouldRun(): bool + { + return $this->cronExpression->isDue(); + } + + /** + * @inheritDoc + */ + public function handle(): void + { + call_user_func($this->callback); + } + + /** + * Get the next run date + * @return \DateTime + */ + public function getNextRunDate(): \DateTime + { + return $this->cronExpression->getNextRunDate(); + } + + /** + * Get the previous run date + * @return \DateTime + */ + public function getPreviousRunDate(): \DateTime + { + return $this->cronExpression->getPreviousRunDate(); + } +} diff --git a/src/Libraries/Cron/Enums/ExceptionMessages.php b/src/Libraries/Cron/Enums/ExceptionMessages.php new file mode 100644 index 00000000..3ab3556a --- /dev/null +++ b/src/Libraries/Cron/Enums/ExceptionMessages.php @@ -0,0 +1,30 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Enums; + +/** + * Enum ExceptionMessages + * @package Quantum\Libraries\Cron + */ +final class ExceptionMessages +{ + public const TASK_NOT_FOUND = 'Cron task "%s" not found'; + public const INVALID_EXPRESSION = 'Invalid cron expression: %s'; + public const LOCK_ACQUIRE_FAILED = 'Failed to acquire lock for task "%s"'; + public const TASK_EXECUTION_FAILED = 'Task "%s" execution failed: %s'; + public const INVALID_TASK_FILE = 'Invalid task file "%s": must return array or CronTask instance'; + public const CRON_DIRECTORY_NOT_FOUND = 'Cron directory not found: %s'; + public const LOCK_DIRECTORY_NOT_WRITABLE = 'Lock directory is not writable: %s'; +} diff --git a/src/Libraries/Cron/Exceptions/CronException.php b/src/Libraries/Cron/Exceptions/CronException.php new file mode 100644 index 00000000..2e5b9eac --- /dev/null +++ b/src/Libraries/Cron/Exceptions/CronException.php @@ -0,0 +1,95 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron\Exceptions; + +use Quantum\Libraries\Cron\Enums\ExceptionMessages; + +/** + * Class CronException + * @package Quantum\Libraries\Cron + */ +class CronException extends \Exception +{ + /** + * Task not found exception + * @param string $taskName + * @return CronException + */ + public static function taskNotFound(string $taskName): CronException + { + return new self(sprintf(ExceptionMessages::TASK_NOT_FOUND, $taskName)); + } + + /** + * Invalid cron expression exception + * @param string $expression + * @return CronException + */ + public static function invalidExpression(string $expression): CronException + { + return new self(sprintf(ExceptionMessages::INVALID_EXPRESSION, $expression)); + } + + /** + * Lock acquire failed exception + * @param string $taskName + * @return CronException + */ + public static function lockAcquireFailed(string $taskName): CronException + { + return new self(sprintf(ExceptionMessages::LOCK_ACQUIRE_FAILED, $taskName)); + } + + /** + * Task execution failed exception + * @param string $taskName + * @param string $error + * @return CronException + */ + public static function taskExecutionFailed(string $taskName, string $error): CronException + { + return new self(sprintf(ExceptionMessages::TASK_EXECUTION_FAILED, $taskName, $error)); + } + + /** + * Invalid task file exception + * @param string $file + * @return CronException + */ + public static function invalidTaskFile(string $file): CronException + { + return new self(sprintf(ExceptionMessages::INVALID_TASK_FILE, $file)); + } + + /** + * Cron directory not found exception + * @param string $directory + * @return CronException + */ + public static function cronDirectoryNotFound(string $directory): CronException + { + return new self(sprintf(ExceptionMessages::CRON_DIRECTORY_NOT_FOUND, $directory)); + } + + /** + * Lock directory not writable exception + * @param string $directory + * @return CronException + */ + public static function lockDirectoryNotWritable(string $directory): CronException + { + return new self(sprintf(ExceptionMessages::LOCK_DIRECTORY_NOT_WRITABLE, $directory)); + } +} diff --git a/src/Libraries/Cron/Helpers/cron.php b/src/Libraries/Cron/Helpers/cron.php new file mode 100644 index 00000000..3816eefc --- /dev/null +++ b/src/Libraries/Cron/Helpers/cron.php @@ -0,0 +1,84 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +use Quantum\Libraries\Cron\CronManager; +use Quantum\Libraries\Cron\CronTask; +use Quantum\Libraries\Cron\Schedule; +use Quantum\Loader\Setup; + +if (!function_exists('cron_config')) { + /** + * Resolve cron configuration value + * @param string $key + * @param mixed $default + * @return mixed|null + */ + function cron_config(string $key, $default = null) + { + static $configLoaded = false; + + if (!$configLoaded) { + try { + if (!config()->has('cron')) { + config()->import(new Setup('config', 'cron')); + } + } catch (\Throwable $exception) { + // Ignore missing cron config file and rely on defaults + } + + $configLoaded = true; + } + + return config()->get('cron.' . $key, $default); + } +} + +if (!function_exists('cron_manager')) { + /** + * Get CronManager instance + * @param string|null $cronDirectory + * @return CronManager + */ + function cron_manager(?string $cronDirectory = null): CronManager + { + return new CronManager($cronDirectory); + } +} + +if (!function_exists('cron_task')) { + /** + * Create a new cron task + * @param string $name + * @param string $expression + * @param callable $callback + * @return CronTask + * @throws \Quantum\Libraries\Cron\Exceptions\CronException + */ + function cron_task(string $name, string $expression, callable $callback): CronTask + { + return new CronTask($name, $expression, $callback); + } +} + +if (!function_exists('schedule')) { + /** + * Create a new schedule with fluent API + * @param string $name + * @return Schedule + */ + function schedule(string $name): Schedule + { + return new Schedule($name); + } +} diff --git a/src/Libraries/Cron/Schedule.php b/src/Libraries/Cron/Schedule.php new file mode 100644 index 00000000..e19d1081 --- /dev/null +++ b/src/Libraries/Cron/Schedule.php @@ -0,0 +1,449 @@ + + * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org) + * @link http://quantum.softberg.org/ + * @since 3.0.0 + */ + +namespace Quantum\Libraries\Cron; + +use Quantum\Libraries\Cron\Exceptions\CronException; + +/** + * Class Schedule + * Fluent API for creating cron schedules + * @package Quantum\Libraries\Cron + */ +class Schedule +{ + /** + * Task name + * @var string + */ + private $name; + + /** + * Cron expression + * @var string + */ + private $expression; + + /** + * Task callback + * @var callable|null + */ + private $callback = null; + + /** + * Schedule constructor + * @param string $name + */ + public function __construct(string $name) + { + $this->name = $name; + } + + /** + * Run the task every minute + * @return self + */ + public function everyMinute(): self + { + $this->expression = '* * * * *'; + return $this; + } + + /** + * Run the task every five minutes + * @return self + */ + public function everyFiveMinutes(): self + { + $this->expression = '*/5 * * * *'; + return $this; + } + + /** + * Run the task every ten minutes + * @return self + */ + public function everyTenMinutes(): self + { + $this->expression = '*/10 * * * *'; + return $this; + } + + /** + * Run the task every fifteen minutes + * @return self + */ + public function everyFifteenMinutes(): self + { + $this->expression = '*/15 * * * *'; + return $this; + } + + /** + * Run the task every thirty minutes + * @return self + */ + public function everyThirtyMinutes(): self + { + $this->expression = '*/30 * * * *'; + return $this; + } + + /** + * Run the task hourly + * @return self + */ + public function hourly(): self + { + $this->expression = '0 * * * *'; + return $this; + } + + /** + * Run the task hourly at a specific minute + * @param int $minute + * @return self + */ + public function hourlyAt(int $minute): self + { + $this->expression = "{$minute} * * * *"; + return $this; + } + + /** + * Run the task every two hours + * @return self + */ + public function everyTwoHours(): self + { + $this->expression = '0 */2 * * *'; + return $this; + } + + /** + * Run the task every three hours + * @return self + */ + public function everyThreeHours(): self + { + $this->expression = '0 */3 * * *'; + return $this; + } + + /** + * Run the task every four hours + * @return self + */ + public function everyFourHours(): self + { + $this->expression = '0 */4 * * *'; + return $this; + } + + /** + * Run the task every six hours + * @return self + */ + public function everySixHours(): self + { + $this->expression = '0 */6 * * *'; + return $this; + } + + /** + * Run the task daily + * @return self + */ + public function daily(): self + { + $this->expression = '0 0 * * *'; + return $this; + } + + /** + * Run the task daily at a specific time + * @param string $time Format: "HH:MM" + * @return self + */ + public function dailyAt(string $time): self + { + [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; + $this->expression = "{$minute} {$hour} * * *"; + return $this; + } + + /** + * Run the task twice daily + * @param int $firstHour + * @param int $secondHour + * @return self + */ + public function twiceDaily(int $firstHour = 1, int $secondHour = 13): self + { + $this->expression = "0 {$firstHour},{$secondHour} * * *"; + return $this; + } + + /** + * Run the task weekly + * @return self + */ + public function weekly(): self + { + $this->expression = '0 0 * * 0'; + return $this; + } + + /** + * Run the task weekly on a specific day and time + * @param int $dayOfWeek 0-6 (Sunday = 0) + * @param string $time Format: "HH:MM" + * @return self + */ + public function weeklyOn(int $dayOfWeek, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; + $this->expression = "{$minute} {$hour} * * {$dayOfWeek}"; + return $this; + } + + /** + * Run the task monthly + * @return self + */ + public function monthly(): self + { + $this->expression = '0 0 1 * *'; + return $this; + } + + /** + * Run the task monthly on a specific day and time + * @param int $dayOfMonth + * @param string $time Format: "HH:MM" + * @return self + */ + public function monthlyOn(int $dayOfMonth = 1, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; + $this->expression = "{$minute} {$hour} {$dayOfMonth} * *"; + return $this; + } + + /** + * Run the task twice monthly + * @param int $firstDay + * @param int $secondDay + * @param string $time + * @return self + */ + public function twiceMonthly(int $firstDay = 1, int $secondDay = 16, string $time = '0:00'): self + { + [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; + $this->expression = "{$minute} {$hour} {$firstDay},{$secondDay} * *"; + return $this; + } + + /** + * Run the task quarterly + * @return self + */ + public function quarterly(): self + { + $this->expression = '0 0 1 1-12/3 *'; + return $this; + } + + /** + * Run the task yearly + * @return self + */ + public function yearly(): self + { + $this->expression = '0 0 1 1 *'; + return $this; + } + + /** + * Run the task on weekdays + * @return self + */ + public function weekdays(): self + { + $this->expression = '0 0 * * 1-5'; + return $this; + } + + /** + * Run the task on weekends + * @return self + */ + public function weekends(): self + { + $this->expression = '0 0 * * 0,6'; + return $this; + } + + /** + * Run the task on Mondays + * @return self + */ + public function mondays(): self + { + return $this->days(1); + } + + /** + * Run the task on Tuesdays + * @return self + */ + public function tuesdays(): self + { + return $this->days(2); + } + + /** + * Run the task on Wednesdays + * @return self + */ + public function wednesdays(): self + { + return $this->days(3); + } + + /** + * Run the task on Thursdays + * @return self + */ + public function thursdays(): self + { + return $this->days(4); + } + + /** + * Run the task on Fridays + * @return self + */ + public function fridays(): self + { + return $this->days(5); + } + + /** + * Run the task on Saturdays + * @return self + */ + public function saturdays(): self + { + return $this->days(6); + } + + /** + * Run the task on Sundays + * @return self + */ + public function sundays(): self + { + return $this->days(0); + } + + /** + * Run the task on specific days + * @param int|array $days + * @return self + */ + public function days($days): self + { + $days = is_array($days) ? implode(',', $days) : $days; + $this->expression = "0 0 * * {$days}"; + return $this; + } + + /** + * Set the time for the task + * @param string $time Format: "HH:MM" + * @return self + */ + public function at(string $time): self + { + [$hour, $minute] = explode(':', $time); + $hour = (int) $hour; + $minute = (int) $minute; + + // Replace hour and minute in existing expression + $parts = explode(' ', $this->expression); + $parts[0] = (string) $minute; + $parts[1] = (string) $hour; + $this->expression = implode(' ', $parts); + + return $this; + } + + /** + * Set custom cron expression + * @param string $expression + * @return self + */ + public function cron(string $expression): self + { + $this->expression = $expression; + return $this; + } + + /** + * Set the callback for the task + * @param callable $callback + * @return self + */ + public function call(callable $callback): self + { + $this->callback = $callback; + return $this; + } + + /** + * Build and return the CronTask + * @return CronTask + * @throws CronException + */ + public function build(): CronTask + { + if ($this->callback === null) { + throw new CronException("Task '{$this->name}' must have a callback. Use call() method."); + } + + if ($this->expression === null) { + throw new CronException("Task '{$this->name}' must have a schedule. Use methods like daily(), hourly(), etc."); + } + + return new CronTask($this->name, $this->expression, $this->callback); + } + + /** + * Get the cron expression + * @return string|null + */ + public function getExpression(): ?string + { + return $this->expression; + } +} diff --git a/tests/Unit/AppTestCase.php b/tests/Unit/AppTestCase.php index deaa1303..cc6e15db 100644 --- a/tests/Unit/AppTestCase.php +++ b/tests/Unit/AppTestCase.php @@ -7,6 +7,7 @@ use Quantum\Environment\Environment; use Quantum\Router\RouteController; use PHPUnit\Framework\TestCase; +use Quantum\Debugger\Debugger; use Quantum\Http\Request; use Quantum\Loader\Setup; use ReflectionClass; @@ -32,6 +33,7 @@ public function tearDown(): void { AppFactory::destroy(App::WEB); config()->flush(); + Debugger::getInstance()->resetStore(); Di::reset(); } diff --git a/tests/Unit/Console/Commands/CronRunCommandTest.php b/tests/Unit/Console/Commands/CronRunCommandTest.php new file mode 100644 index 00000000..9adf5854 --- /dev/null +++ b/tests/Unit/Console/Commands/CronRunCommandTest.php @@ -0,0 +1,307 @@ +cronDirectory = base_dir() . DS . 'cron-command-tests'; + $this->lockDirectory = base_dir() . DS . 'cron-command-locks'; + $this->cleanupDirectory($this->cronDirectory); + $this->cleanupDirectory($this->lockDirectory); + mkdir($this->cronDirectory, 0777, true); + mkdir($this->lockDirectory, 0777, true); + + config()->set('logging', [ + 'default' => 'single', + 'single' => [ + 'driver' => 'single', + 'path' => base_dir() . '/logs', + 'level' => 'debug', + ], + ]); + + config()->set('cron', [ + 'path' => null, + 'lock_path' => $this->lockDirectory, + 'max_lock_age' => 86400, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + + $this->cleanupDirectory($this->cronDirectory); + $this->cleanupDirectory($this->lockDirectory); + } + + public function testCommandExecutesSuccessfully() + { + $this->createTaskFile('test-task.php', [ + 'name' => 'test-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Running scheduled tasks', $output); + $this->assertStringContainsString('Execution Summary', $output); + } + + public function testCommandWithNoTasks() + { + $emptyDir = $this->cronDirectory . '-empty'; + $this->cleanupDirectory($emptyDir); + mkdir($emptyDir, 0777, true); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $emptyDir]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('No tasks found', $output); + } + + public function testCommandWithSpecificTask() + { + $this->createTaskFile('specific-task.php', [ + 'name' => 'specific-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--task' => 'specific-task', + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Running task: specific-task', $output); + } + + public function testCommandWithForceOption() + { + $this->createTaskFile('force-task.php', [ + 'name' => 'force-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--force' => true, + ]); + + $this->assertEquals(0, $tester->getStatusCode()); + } + + public function testCommandWithNonExistentTask() + { + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '--task' => 'non-existent', + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('not found', $output); + } + + public function testCommandDisplaysStatistics() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 0 1 1 *', + 'callback' => function () {}, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Total tasks:', $output); + $this->assertStringContainsString('Executed:', $output); + $this->assertStringContainsString('Skipped:', $output); + } + + public function testCommandHandlesTaskFailure() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => "throw new \\Exception('Task failed');", + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Failed: 1', $output); + } + + public function testCommandShortOptions() + { + $this->createTaskFile('short-option-task.php', [ + 'name' => 'short-option-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([ + '--path' => $this->cronDirectory, + '-t' => 'short-option-task', + ]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('short-option-task', $output); + } + + public function testCommandUsesConfiguredPath() + { + $this->createTaskFile('config-task.php', [ + 'name' => 'config-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + config()->set('cron', [ + 'path' => $this->cronDirectory, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute([]); + + $output = $tester->getDisplay(); + + $this->assertStringContainsString('Execution Summary', $output); + } + + public function testCommandReportsLockedTasks() + { + $this->createTaskFile('locked-task.php', [ + 'name' => 'locked-task', + 'expression' => '* * * * *', + 'callback' => function () {}, + ]); + + $lock = new \Quantum\Libraries\Cron\CronLock('locked-task', $this->lockDirectory); + $lock->acquire(); + + $command = new CronRunCommand(); + $tester = new CommandTester($command); + + $tester->execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Locked: 1', $output); + + $lock->release(); + } + + public function testCommandHandlesUnexpectedError() + { + $invalidTask = $this->cronDirectory . DS . 'invalid-task.php'; + file_put_contents($invalidTask, "execute(['--path' => $this->cronDirectory]); + + $output = $tester->getDisplay(); + $this->assertStringContainsString('Unexpected error', $output); + } + + private function createTaskFile(string $filename, array $definition): void + { + $body = $definition['body'] ?? "echo 'Test task executed';"; + + $content = " '{$definition['name']}',\n"; + $content .= " 'expression' => '{$definition['expression']}',\n"; + $content .= " 'callback' => function () {\n"; + $content .= " {$body}\n"; + $content .= " }\n"; + $content .= "];\n"; + + file_put_contents($this->cronDirectory . DS . $filename, $content); + } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); + } +} diff --git a/tests/Unit/Libraries/Cron/CronExceptionTest.php b/tests/Unit/Libraries/Cron/CronExceptionTest.php new file mode 100644 index 00000000..af789013 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronExceptionTest.php @@ -0,0 +1,51 @@ +assertEquals('Cron task "my-task" not found', $exception->getMessage()); + } + + public function testInvalidExpression() + { + $exception = CronException::invalidExpression('invalid-expr'); + $this->assertEquals('Invalid cron expression: invalid-expr', $exception->getMessage()); + } + + public function testLockAcquireFailed() + { + $exception = CronException::lockAcquireFailed('my-task'); + $this->assertEquals('Failed to acquire lock for task "my-task"', $exception->getMessage()); + } + + public function testTaskExecutionFailed() + { + $exception = CronException::taskExecutionFailed('my-task', 'Connection timeout'); + $this->assertEquals('Task "my-task" execution failed: Connection timeout', $exception->getMessage()); + } + + public function testInvalidTaskFile() + { + $exception = CronException::invalidTaskFile('invalid-file.php'); + $this->assertEquals('Invalid task file "invalid-file.php": must return array or CronTask instance', $exception->getMessage()); + } + + public function testCronDirectoryNotFound() + { + $exception = CronException::cronDirectoryNotFound('/path/to/cron'); + $this->assertEquals('Cron directory not found: /path/to/cron', $exception->getMessage()); + } + + public function testLockDirectoryNotWritable() + { + $exception = CronException::lockDirectoryNotWritable('/path/to/lock'); + $this->assertEquals('Lock directory is not writable: /path/to/lock', $exception->getMessage()); + } +} diff --git a/tests/Unit/Libraries/Cron/CronHelperTest.php b/tests/Unit/Libraries/Cron/CronHelperTest.php new file mode 100644 index 00000000..0d2865f2 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronHelperTest.php @@ -0,0 +1,48 @@ +has('cron')) { + config()->set('cron', [ + 'path' => '/default/path', + 'lock_path' => '/default/lock', + ]); + } + } + + public function testCronConfig() + { + $this->assertEquals('/default/path', cron_config('path')); + $this->assertEquals('default-val', cron_config('non-existent', 'default-val')); + } + + public function testCronManagerHelper() + { + $manager = cron_manager('/custom/path'); + $this->assertInstanceOf(CronManager::class, $manager); + } + + public function testCronTaskHelper() + { + $task = cron_task('my-task', '* * * * *', function () {}); + $this->assertInstanceOf(CronTask::class, $task); + $this->assertEquals('my-task', $task->getName()); + } + + public function testScheduleHelper() + { + $schedule = schedule('my-task'); + $this->assertInstanceOf(Schedule::class, $schedule); + } +} diff --git a/tests/Unit/Libraries/Cron/CronLockTest.php b/tests/Unit/Libraries/Cron/CronLockTest.php new file mode 100644 index 00000000..e577e4fb --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronLockTest.php @@ -0,0 +1,279 @@ +lockDirectory = base_dir() . DS . 'runtime' . DS . 'cron-lock-tests'; + $this->cleanupDirectory($this->lockDirectory); + + config()->set('cron', [ + 'path' => null, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + } + + public function tearDown(): void + { + parent::tearDown(); + + $this->cleanupDirectory($this->lockDirectory); + } + + public function testConstructorCreatesLockDirectory() + { + $lock = new CronLock('test-task', $this->lockDirectory); + + $this->assertTrue(is_dir($this->lockDirectory)); + $lock->release(); + } + + public function testAcquireLock() + { + $lock = new CronLock('test-task', $this->lockDirectory); + + $this->assertTrue($lock->acquire()); + $lock->release(); + } + + public function testCannotAcquireLockedTask() + { + $lock1 = new CronLock('test-task', $this->lockDirectory); + $lock1->acquire(); + + $lock2 = new CronLock('test-task', $this->lockDirectory); + + $this->assertFalse($lock2->acquire()); + + $lock1->release(); + } + + public function testReleaseLock() + { + $lock = new CronLock('test-task', $this->lockDirectory); + $lock->acquire(); + + $this->assertTrue($lock->release()); + $this->assertFalse($lock->isLocked()); + } + + public function testReleaseWithoutAcquireDoesNotDeleteForeignLock() + { + $lockPath = $this->lockDirectory . DS . 'foreign-task.lock'; + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, 'foreign'); + + $lock = new CronLock('foreign-task', $this->lockDirectory); + $lock->release(); + + $this->assertFileExists($lockPath); + @unlink($lockPath); + } + + public function testIsLocked() + { + $lock1 = new CronLock('test-task', $this->lockDirectory); + + $this->assertFalse($lock1->isLocked()); + + $lock1->acquire(); + + $lock2 = new CronLock('test-task', $this->lockDirectory); + $this->assertTrue($lock2->isLocked()); + + $lock1->release(); + + $this->assertFalse($lock2->isLocked()); + } + + public function testMultipleTasksCanHaveSeparateLocks() + { + $lock1 = new CronLock('task-1', $this->lockDirectory); + $lock2 = new CronLock('task-2', $this->lockDirectory); + + $this->assertTrue($lock1->acquire()); + $this->assertTrue($lock2->acquire()); + + $lock1->release(); + $lock2->release(); + } + + public function testRefreshUpdatesTimestamp() + { + $lock = new CronLock('refresh-task', $this->lockDirectory); + $lock->acquire(); + + $lockFile = $this->lockDirectory . DS . 'refresh-task.lock'; + $initial = (int) file_get_contents($lockFile); + + sleep(1); + $this->assertTrue($lock->refresh()); + $updated = (int) file_get_contents($lockFile); + + $this->assertGreaterThan($initial, $updated); + + $lock->release(); + } + + public function testTaskNameIsSanitized() + { + $lock = new CronLock('../bad name', $this->lockDirectory); + $this->assertTrue($lock->acquire()); + + $expectedPath = $this->lockDirectory . DS . 'bad_name.lock'; + $this->assertFileExists($expectedPath); + + $lock->release(); + @unlink($expectedPath); + } + + public function testStaleLocksAreCleanedUp() + { + $lockPath = $this->lockDirectory . DS . 'stale-task.lock'; + $this->cleanupDirectory($this->lockDirectory); + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, (string) (time() - 90000)); + + new CronLock('stale-task', $this->lockDirectory, 10); + + $this->assertFalse(file_exists($lockPath)); + } + + public function testCleanupSkipsActiveLocks() + { + $lockPath = $this->lockDirectory . DS . 'active.lock'; + $this->cleanupDirectory($this->lockDirectory); + mkdir($this->lockDirectory, 0777, true); + file_put_contents($lockPath, (string) (time() - 90000)); + + $handle = fopen($lockPath, 'c+'); + flock($handle, LOCK_EX); + touch($lockPath, time() - 90000); + + new CronLock('dummy', $this->lockDirectory, 10); + + $this->assertFileExists($lockPath); + + flock($handle, LOCK_UN); + fclose($handle); + + new CronLock('dummy', $this->lockDirectory, 10); + + $this->assertFileDoesNotExist($lockPath); + } + + public function testConfigurableLockDirectoryIsUsed() + { + $customDirectory = base_dir() . DS . 'runtime' . DS . 'cron-custom-locks'; + $this->cleanupDirectory($customDirectory); + + config()->set('cron', [ + 'path' => null, + 'lock_path' => $customDirectory, + 'max_lock_age' => 86400, + ]); + + $lock = new CronLock('custom-task'); + $lock->acquire(); + $lock->release(); + + $this->assertTrue(is_dir($customDirectory)); + + $this->cleanupDirectory($customDirectory); + } + + public function testThrowsExceptionWhenDirectoryNotWritable() + { + if (function_exists('posix_getuid') && posix_getuid() === 0) { + $this->markTestSkipped('Skipping non-writable directory test as root.'); + } + + $this->expectException(CronException::class); + $this->expectExceptionMessage('not writable'); + + $readOnlyDir = $this->lockDirectory . DS . 'readonly'; + mkdir($this->lockDirectory, 0777, true); + mkdir($readOnlyDir, 0444); + + try { + new CronLock('test-task', $readOnlyDir); + } finally { + chmod($readOnlyDir, 0755); + } + } + + public function testCronLockRefresh() + { + $lock = new CronLock('refresh-task', $this->lockDirectory); + $this->assertFalse($lock->refresh()); // Not owned yet + + $lock->acquire(); + $this->assertTrue($lock->refresh()); + $lock->release(); + } + + public function testCronLockSanitization() + { + $lock = new CronLock(' Space Task / \\ ', $this->lockDirectory); + $this->assertStringContainsString('Space_Task', $this->getPrivateProperty($lock, 'lockFile')); + + $lock2 = new CronLock('', $this->lockDirectory); + $this->assertStringContainsString('default.lock', $this->getPrivateProperty($lock2, 'lockFile')); + } + + public function testCronLockDirectoryRecursion() + { + $nestedDir = $this->lockDirectory . DS . 'a' . DS . 'b' . DS . 'c'; + $lock = new CronLock('nested-task', $nestedDir); + $this->assertTrue(fs()->isDirectory($nestedDir)); + $lock->acquire(); + $this->assertTrue(fs()->exists($nestedDir . DS . 'nested-task.lock')); + $lock->release(); + } + + public function testCronLockEmptyDirectoryThrowsException() + { + $this->expectException(CronException::class); + new CronLock('task', ''); + } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); + } +} diff --git a/tests/Unit/Libraries/Cron/CronManagerTest.php b/tests/Unit/Libraries/Cron/CronManagerTest.php new file mode 100644 index 00000000..c72ac318 --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronManagerTest.php @@ -0,0 +1,301 @@ +cronDirectory = base_dir() . DS . 'cron-tests'; + $this->cleanupDirectory($this->cronDirectory); + mkdir($this->cronDirectory, 0777, true); + self::$executedTasks = []; + + // Setup logging config to avoid Loader dependency + if (!config()->has('logging')) { + config()->set('logging', [ + 'default' => 'single', + 'single' => [ + 'driver' => 'single', + 'path' => base_dir() . '/logs', + 'level' => 'debug', + ], + ]); + } + + config()->set('cron', [ + 'path' => null, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + } + + public function testLoadTasksFromDirectory() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 * * * *', + ]); + + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $tasks = $manager->getTasks(); + + $this->assertCount(2, $tasks); + $this->assertArrayHasKey('task-1', $tasks); + $this->assertArrayHasKey('task-2', $tasks); + } + + public function testLoadTasksWithObjectFormat() + { + $taskContent = 'cronDirectory . '/object-task.php', $taskContent); + + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $tasks = $manager->getTasks(); + + $this->assertCount(1, $tasks); + $this->assertArrayHasKey('object-task', $tasks); + } + + public function testLoadTasksWithEmptyDirectory() + { + $manager = new CronManager($this->cronDirectory); + $manager->loadTasks(); + + $this->assertCount(0, $manager->getTasks()); + } + + public function testRunDueTasksExecutesOnlyDueTasks() + { + $this->createTaskFile('due-task.php', [ + 'name' => 'due-task', + 'expression' => '* * * * *', // Always due + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('due-task');", + ]); + + $this->createTaskFile('not-due-task.php', [ + 'name' => 'not-due-task', + 'expression' => '0 0 1 1 *', // Once a year (Jan 1st) + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('not-due-task');", + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertContains('due-task', self::$executedTasks); + $this->assertNotContains('not-due-task', self::$executedTasks); + $this->assertEquals(1, $stats['executed']); + $this->assertEquals(1, $stats['skipped']); + } + + public function testRunTaskByName() + { + $this->createTaskFile('specific-task.php', [ + 'name' => 'specific-task', + 'expression' => '* * * * *', + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('specific-task');", + ]); + + $manager = new CronManager($this->cronDirectory); + $manager->runTaskByName('specific-task'); + + $this->assertContains('specific-task', self::$executedTasks); + } + + public function testRunTaskByNameThrowsExceptionForNonExistentTask() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('not found'); + + $manager = new CronManager($this->cronDirectory); + $manager->runTaskByName('non-existent-task'); + } + + public function testRunDueTasksWithForceIgnoresLocks() + { + $this->createTaskFile('locked-task.php', [ + 'name' => 'locked-task', + 'expression' => '* * * * *', + 'body' => "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('locked-task');", + ]); + + $manager = new CronManager($this->cronDirectory); + + // Run twice with force - should execute both times + $manager->runDueTasks(true); + $manager->runDueTasks(true); + + $occurrences = array_filter(self::$executedTasks, function ($task) { + return $task === 'locked-task'; + }); + $this->assertCount(2, $occurrences); + } + + public function testTaskExecutionFailureIsHandled() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => "throw new \\Exception('Task failed');", + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertEquals(0, $stats['executed']); + $this->assertEquals(1, $stats['failed']); + } + + public function testGetStatsReturnsCorrectStatistics() + { + $this->createTaskFile('task1.php', [ + 'name' => 'task-1', + 'expression' => '* * * * *', + ]); + + $this->createTaskFile('task2.php', [ + 'name' => 'task-2', + 'expression' => '0 0 1 1 *', + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(); + + $this->assertEquals(2, $stats['total']); + $this->assertEquals(1, $stats['executed']); + $this->assertEquals(1, $stats['skipped']); + $this->assertEquals(0, $stats['failed']); + $this->assertEquals(0, $stats['locked']); + } + + public function testInvalidTaskFileThrowsException() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('Invalid task file'); + + file_put_contents($this->cronDirectory . '/invalid.php', 'cronDirectory); + $manager->loadTasks(); + } + + public function testCronDirectoryCanBeConfigured() + { + $configuredDir = base_dir() . DS . 'cron-configured'; + $this->cleanupDirectory($configuredDir); + mkdir($configuredDir, 0777, true); + + $this->createTaskFile('configured-task.php', [ + 'name' => 'configured-task', + 'expression' => '* * * * *', + ], $configuredDir); + + config()->set('cron', [ + 'path' => $configuredDir, + 'lock_path' => null, + 'max_lock_age' => 86400, + ]); + + $manager = new CronManager(); + $manager->loadTasks(); + + $this->assertArrayHasKey('configured-task', $manager->getTasks()); + + $this->cleanupDirectory($configuredDir); + } + + public function testCronDirectoryNotFoundThrowsException() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('not found'); + + $manager = new CronManager($this->cronDirectory . '/non-existent'); + $manager->loadTasks(); + } + + public function testTaskExecutionFailureIsRecorded() + { + $this->createTaskFile('failing-task.php', [ + 'name' => 'failing-task', + 'expression' => '* * * * *', + 'body' => 'throw new \Exception("Execution failed");', + ]); + + $manager = new CronManager($this->cronDirectory); + $stats = $manager->runDueTasks(true); + + $this->assertEquals(1, $stats['failed']); + } + + private function createTaskFile(string $filename, array $definition, ?string $directory = null): void + { + $directory = $directory ?? $this->cronDirectory; + $body = $definition['body'] ?? "\\Quantum\\Tests\\Unit\\Libraries\\Cron\\CronManagerTest::recordExecution('{$definition['name']}');"; + + $content = " '{$definition['name']}',\n"; + $content .= " 'expression' => '{$definition['expression']}',\n"; + $content .= " 'callback' => function () {\n"; + $content .= " {$body}\n"; + $content .= " }\n"; + $content .= "];\n"; + + file_put_contents($directory . DS . $filename, $content); + } + + public static function recordExecution(string $taskName): void + { + self::$executedTasks[] = $taskName; + } + + private function cleanupDirectory(string $directory): void + { + if (!is_dir($directory)) { + return; + } + + $items = scandir($directory); + + foreach ($items as $item) { + if (in_array($item, ['.', '..'], true)) { + continue; + } + + $path = $directory . DS . $item; + + if (is_dir($path)) { + $this->cleanupDirectory($path); + } elseif (file_exists($path)) { + @unlink($path); + } + } + + @rmdir($directory); + } +} diff --git a/tests/Unit/Libraries/Cron/CronTaskTest.php b/tests/Unit/Libraries/Cron/CronTaskTest.php new file mode 100644 index 00000000..c03bb4ec --- /dev/null +++ b/tests/Unit/Libraries/Cron/CronTaskTest.php @@ -0,0 +1,107 @@ +assertEquals('test-task', $task->getName()); + $this->assertEquals('* * * * *', $task->getExpression()); + } + + public function testConstructorWithInvalidExpression() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage('Invalid cron expression'); + + new CronTask('test-task', 'invalid', function () {}); + } + + public function testShouldRunEveryMinute() + { + $task = new CronTask('test-task', '* * * * *', function () {}); + + $this->assertTrue($task->shouldRun()); + } + + public function testShouldNotRunFutureTask() + { + // Task scheduled for next year + $task = new CronTask('test-task', '0 0 1 1 *', function () {}); + + $this->assertFalse($task->shouldRun()); + } + + public function testHandleExecutesCallback() + { + $executed = false; + + $task = new CronTask('test-task', '* * * * *', function () use (&$executed) { + $executed = true; + }); + + $task->handle(); + + $this->assertTrue($executed); + } + + public function testHandleWithCallbackArguments() + { + $result = null; + + $task = new CronTask('test-task', '* * * * *', function () use (&$result) { + $result = 'executed'; + }); + + $task->handle(); + + $this->assertEquals('executed', $result); + } + + public function testGetNextRunDate() + { + $task = new CronTask('test-task', '0 0 * * *', function () {}); + + $nextRun = $task->getNextRunDate(); + + $this->assertInstanceOf(\DateTime::class, $nextRun); + $this->assertGreaterThan(new \DateTime(), $nextRun); + } + + public function testGetPreviousRunDate() + { + $task = new CronTask('test-task', '0 0 * * *', function () {}); + + $previousRun = $task->getPreviousRunDate(); + + $this->assertInstanceOf(\DateTime::class, $previousRun); + $this->assertLessThan(new \DateTime(), $previousRun); + } + + public function testComplexCronExpression() + { + // Every 5 minutes + $task = new CronTask('test-task', '*/5 * * * *', function () {}); + + $this->assertEquals('*/5 * * * *', $task->getExpression()); + } + + public function testWeeklyCronExpression() + { + // Every Monday at 9 AM + $task = new CronTask('test-task', '0 9 * * 1', function () {}); + + $this->assertEquals('0 9 * * 1', $task->getExpression()); + } +} diff --git a/tests/Unit/Libraries/Cron/ScheduleTest.php b/tests/Unit/Libraries/Cron/ScheduleTest.php new file mode 100644 index 00000000..e2d92309 --- /dev/null +++ b/tests/Unit/Libraries/Cron/ScheduleTest.php @@ -0,0 +1,241 @@ +schedule = new Schedule('test-task'); + } + + public function testEveryMinute() + { + $this->schedule->everyMinute(); + $this->assertEquals('* * * * *', $this->schedule->getExpression()); + } + + public function testEveryFiveMinutes() + { + $this->schedule->everyFiveMinutes(); + $this->assertEquals('*/5 * * * *', $this->schedule->getExpression()); + } + + public function testEveryTenMinutes() + { + $this->schedule->everyTenMinutes(); + $this->assertEquals('*/10 * * * *', $this->schedule->getExpression()); + } + + public function testEveryFifteenMinutes() + { + $this->schedule->everyFifteenMinutes(); + $this->assertEquals('*/15 * * * *', $this->schedule->getExpression()); + } + + public function testEveryThirtyMinutes() + { + $this->schedule->everyThirtyMinutes(); + $this->assertEquals('*/30 * * * *', $this->schedule->getExpression()); + } + + public function testHourly() + { + $this->schedule->hourly(); + $this->assertEquals('0 * * * *', $this->schedule->getExpression()); + } + + public function testHourlyAt() + { + $this->schedule->hourlyAt(15); + $this->assertEquals('15 * * * *', $this->schedule->getExpression()); + } + + public function testEveryTwoHours() + { + $this->schedule->everyTwoHours(); + $this->assertEquals('0 */2 * * *', $this->schedule->getExpression()); + } + + public function testEveryThreeHours() + { + $this->schedule->everyThreeHours(); + $this->assertEquals('0 */3 * * *', $this->schedule->getExpression()); + } + + public function testEveryFourHours() + { + $this->schedule->everyFourHours(); + $this->assertEquals('0 */4 * * *', $this->schedule->getExpression()); + } + + public function testEverySixHours() + { + $this->schedule->everySixHours(); + $this->assertEquals('0 */6 * * *', $this->schedule->getExpression()); + } + + public function testDaily() + { + $this->schedule->daily(); + $this->assertEquals('0 0 * * *', $this->schedule->getExpression()); + } + + public function testDailyAt() + { + $this->schedule->dailyAt('13:30'); + $this->assertEquals('30 13 * * *', $this->schedule->getExpression()); + } + + public function testTwiceDaily() + { + $this->schedule->twiceDaily(4, 16); + $this->assertEquals('0 4,16 * * *', $this->schedule->getExpression()); + } + + public function testWeekly() + { + $this->schedule->weekly(); + $this->assertEquals('0 0 * * 0', $this->schedule->getExpression()); + } + + public function testWeeklyOn() + { + $this->schedule->weeklyOn(1, '15:45'); + $this->assertEquals('45 15 * * 1', $this->schedule->getExpression()); + } + + public function testMonthly() + { + $this->schedule->monthly(); + $this->assertEquals('0 0 1 * *', $this->schedule->getExpression()); + } + + public function testMonthlyOn() + { + $this->schedule->monthlyOn(15, '10:00'); + $this->assertEquals('0 10 15 * *', $this->schedule->getExpression()); + } + + public function testTwiceMonthly() + { + $this->schedule->twiceMonthly(1, 15, '12:00'); + $this->assertEquals('0 12 1,15 * *', $this->schedule->getExpression()); + } + + public function testQuarterly() + { + $this->schedule->quarterly(); + $this->assertEquals('0 0 1 1-12/3 *', $this->schedule->getExpression()); + } + + public function testYearly() + { + $this->schedule->yearly(); + $this->assertEquals('0 0 1 1 *', $this->schedule->getExpression()); + } + + public function testWeekdays() + { + $this->schedule->weekdays(); + $this->assertEquals('0 0 * * 1-5', $this->schedule->getExpression()); + } + + public function testWeekends() + { + $this->schedule->weekends(); + $this->assertEquals('0 0 * * 0,6', $this->schedule->getExpression()); + } + + public function testMondays() + { + $this->schedule->mondays(); + $this->assertEquals('0 0 * * 1', $this->schedule->getExpression()); + } + + public function testTuesdays() + { + $this->schedule->tuesdays(); + $this->assertEquals('0 0 * * 2', $this->schedule->getExpression()); + } + + public function testWednesdays() + { + $this->schedule->wednesdays(); + $this->assertEquals('0 0 * * 3', $this->schedule->getExpression()); + } + + public function testThursdays() + { + $this->schedule->thursdays(); + $this->assertEquals('0 0 * * 4', $this->schedule->getExpression()); + } + + public function testFridays() + { + $this->schedule->fridays(); + $this->assertEquals('0 0 * * 5', $this->schedule->getExpression()); + } + + public function testSaturdays() + { + $this->schedule->saturdays(); + $this->assertEquals('0 0 * * 6', $this->schedule->getExpression()); + } + + public function testSundays() + { + $this->schedule->sundays(); + $this->assertEquals('0 0 * * 0', $this->schedule->getExpression()); + } + + public function testDaysWithArray() + { + $this->schedule->days([1, 3, 5]); + $this->assertEquals('0 0 * * 1,3,5', $this->schedule->getExpression()); + } + + public function testAtOverridesTime() + { + $this->schedule->weeklyOn(1)->at('14:30'); + $this->assertEquals('30 14 * * 1', $this->schedule->getExpression()); + } + + public function testCronSchedulesCustomExpression() + { + $this->schedule->cron('1 2 3 4 5'); + $this->assertEquals('1 2 3 4 5', $this->schedule->getExpression()); + } + + public function testBuildSetsTask() + { + $callback = function () {}; + $task = $this->schedule->everyMinute()->call($callback)->build(); + + $this->assertInstanceOf(CronTask::class, $task); + $this->assertEquals('test-task', $task->getName()); + $this->assertEquals('* * * * *', $task->getExpression()); + } + + public function testBuildThrowsExceptionWhenCallbackMissing() + { + $this->expectException(CronException::class); + $this->expectExceptionMessage("Task 'test-task' must have a callback. Use call() method."); + $this->schedule->everyMinute()->build(); + } + + public function testBuildThrowsExceptionWhenScheduleMissing() + { + $callback = function () {}; + $this->expectException(CronException::class); + $this->expectExceptionMessage("Task 'test-task' must have a schedule. Use methods like daily(), hourly(), etc."); + $this->schedule->call($callback)->build(); + } +} diff --git a/tests/_root/shared/config/cron.php b/tests/_root/shared/config/cron.php new file mode 100644 index 00000000..7d837613 --- /dev/null +++ b/tests/_root/shared/config/cron.php @@ -0,0 +1,7 @@ + env('CRON_PATH'), + 'lock_path' => env('CRON_LOCK_PATH'), + 'max_lock_age' => (int)env('CRON_MAX_LOCK_AGE', 86400), +];