diff --git a/composer.json b/composer.json index cd1487e..0588b86 100644 --- a/composer.json +++ b/composer.json @@ -1,11 +1,11 @@ { - "name": "doppar/guard", - "description": "A authorization package for doppar framework", + "name": "doppar/queue", + "description": "A lightweight queue management library for the Doppar framework.", "type": "library", "license": "MIT", "support": { - "issues": "https://github.com/doppar/guard/issues", - "source": "https://github.com/doppar/guard" + "issues": "https://github.com/doppar/queue/issues", + "source": "https://github.com/doppar/queue" }, "authors": [ { @@ -15,16 +15,17 @@ ], "require-dev": { "mockery/mockery": "^1.6", - "phpunit/phpunit": "^12.1.5" + "phpunit/phpunit": "^12.1.5", + "doppar/framework": "^3.0.0" }, "autoload": { "psr-4": { - "Doppar\\Authorizer\\": "src/" + "Doppar\\Queue\\": "src/" } }, "autoload-dev": { "psr-4": { - "Doppar\\Authorizer\\Tests\\": "tests/" + "Doppar\\Queue\\Tests\\": "tests/" } }, "extra": { @@ -33,7 +34,7 @@ }, "doppar": { "providers": [ - "Doppar\\Authorizer\\GuardServiceProvider" + "Doppar\\Queue\\QueueServiceProvider" ] } }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 88c6019..7ff869c 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,11 +1,19 @@ - + - - ./tests/Unit + + ./tests - + + + + + \ No newline at end of file diff --git a/src/Commands/MakeJobCommand.php b/src/Commands/MakeJobCommand.php new file mode 100644 index 0000000..00e8de8 --- /dev/null +++ b/src/Commands/MakeJobCommand.php @@ -0,0 +1,107 @@ +executeWithTiming(function () { + $name = $this->argument('name'); + $parts = explode('/', $name); + $className = array_pop($parts); + + // Ensure class name ends with Job + if (!str_ends_with($className, 'Job')) { + $className .= 'Job'; + } + + $namespace = 'App\\Jobs' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : ''); + $filePath = base_path('app/Jobs/' . str_replace('/', DIRECTORY_SEPARATOR, $name) . '.php'); + + // Check if Job already exists + if (file_exists($filePath)) { + $this->displayError('Job already exists at:'); + $this->line('' . str_replace(base_path(), '', $filePath) . ''); + return Command::FAILURE; + } + + // Create directory if needed + $directoryPath = dirname($filePath); + if (!is_dir($directoryPath)) { + mkdir($directoryPath, 0755, true); + } + + // Generate and save Job class + $content = $this->generateJobContent($namespace, $className); + file_put_contents($filePath, $content); + + $this->displaySuccess('Job created successfully'); + $this->line('📦 File: ' . str_replace(base_path(), '', $filePath) . ''); + $this->newLine(); + $this->line('⚙️ Class: ' . $className . ''); + + return Command::SUCCESS; + }); + } + + /** + * Generate Job class content. + */ + protected function generateJobContent(string $namespace, string $className): string + { + return <<get(); + + if ($failedJobs->isEmpty()) { + $this->info("No failed jobs found."); + return Command::SUCCESS; + } + + // Create table + $table = $this->createTable(); + $table->setHeaders(['ID', 'Job', 'Queue', 'Failed At']); + + foreach ($failedJobs as $job) { + $payload = $job->payload; + $data = unserialize($payload); + + $jobClass = null; + if ($data && isset($data['job']) && is_object($data['job'])) { + $jobClass = get_class($data['job']); + } + + $failedAt = date('Y-m-d H:i:s', $job->failed_at); + $table->addRow([ + $job->id, + $jobClass, + $job->queue, + $failedAt + ]); + } + + // Render table + $table->render(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/QueueFlushCommand.php b/src/Commands/QueueFlushCommand.php new file mode 100644 index 0000000..6d3e058 --- /dev/null +++ b/src/Commands/QueueFlushCommand.php @@ -0,0 +1,62 @@ +option('id'); + + if ($id) { + return $this->flushJobById($id); + } + + FailedJob::query() + ->cursor(function (FailedJob $failedJob) { + $failedJob->delete(); + $this->info("✔ Job with ID {$failedJob->id} has been deleted."); + }); + + return Command::SUCCESS; + } + + protected function flushJobById(int $id): int + { + $failedJob = FailedJob::find($id); + + if (!$failedJob) { + $this->error("Failed job with ID {$id} not found."); + return Command::FAILURE; + } + + $failedJob->delete(); + $this->info("✔ Job with ID {$failedJob->id} has been deleted."); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/QueueMonitorCommand.php b/src/Commands/QueueMonitorCommand.php new file mode 100644 index 0000000..0417fa3 --- /dev/null +++ b/src/Commands/QueueMonitorCommand.php @@ -0,0 +1,71 @@ +pluck('queue'); + + // Create table for queue statistics + $table = $this->createTable(); + $table->setHeaders(['Queue', 'Pending', 'Processing']); + + foreach ($queues ?? [] as $queue) { + $pending = QueueJob::where('queue', $queue) + ->whereNull('reserved_at') + ->count(); + + $processing = QueueJob::where('queue', $queue) + ->whereNotNull('reserved_at') + ->count(); + + $table->addRow([ + $queue, + $pending, + $processing, + ]); + } + + // Render queue table + $this->newLine(); + $this->info("Queue Statistics"); + $table->render(); + + // Failed jobs table + $failedCount = FailedJob::count(); + + $failedTable = $this->createTable(); + $failedTable->setHeaders(['Metric', 'Value']); + $failedTable->addRow(['Failed Jobs', $failedCount]); + + $this->info("\nFailed Jobs Summary"); + $failedTable->render(); + + return Command::SUCCESS; + } +} diff --git a/src/Commands/QueueRetryCommand.php b/src/Commands/QueueRetryCommand.php new file mode 100644 index 0000000..af8f80a --- /dev/null +++ b/src/Commands/QueueRetryCommand.php @@ -0,0 +1,86 @@ +option('id'); + $manager = app(QueueManager::class); + + if ($id) { + return $this->retryJobById($manager, $id); + } + + FailedJob::query() + ->cursor(function (FailedJob $failedJob) use ($manager) { + $this->retryFailedJob($manager, $failedJob); + }); + + return Command::SUCCESS; + } + + protected function retryJobById(QueueManager $manager, int $id): int + { + $failedJob = FailedJob::find($id); + + if (!$failedJob) { + $this->error("Failed job with ID {$id} not found."); + return Command::FAILURE; + } + + if ($this->retryFailedJob($manager, $failedJob)) { + return Command::SUCCESS; + } + + return Command::FAILURE; + } + + protected function retryFailedJob(QueueManager $manager, FailedJob $failedJob): bool + { + try { + $job = $manager->unserializeJob($failedJob->payload); + $jobClass = get_class($job); + + // Reset attempts + $job->attempts = 0; + + // Push back to queue + $manager->push($job); + + // Delete from failed jobs + $failedJob->delete(); + + $this->info("✔ Retried job [{$jobClass}] (ID: {$failedJob->id})"); + return true; + } catch (\Throwable $e) { + $this->error("✖ Failed to retry job ID {$failedJob->id}: " . $e->getMessage()); + return false; + } + } +} diff --git a/src/Commands/QueueRunCommand.php b/src/Commands/QueueRunCommand.php index 7ada465..bc51931 100644 --- a/src/Commands/QueueRunCommand.php +++ b/src/Commands/QueueRunCommand.php @@ -50,21 +50,37 @@ public function __construct(QueueManager $manager) /** * Execute the console command. + * Example: php pool queue:run --queue=reports --sleep=10 --memory=1024 --timeout=3600 * * @return int */ protected function handle(): int { return $this->withTiming(function () { - $queue = $this->getOption('queue', 'default'); - $sleep = (int) $this->getOption('sleep', 3); - $maxMemory = (int) $this->getOption('memory', 128); - $maxTime = (int) $this->getOption('timeout', 3600); + $queue = $this->option('queue', 'default'); + $sleep = (int) $this->option('sleep', 3); + $maxMemory = (int) $this->option('memory', 128); + $maxTime = (int) $this->option('timeout', 3600); $this->info("Starting queue worker on queue: {$queue}"); $this->info("Configuration: sleep={$sleep}s, memory={$maxMemory}MB, timeout={$maxTime}s"); try { + $this->worker->setOnJobProcessing(function ($job) { + $jobClass = get_class($job); + $jobId = $job->getJobId() ?? 'N/A'; + $this->info("✔ Processing job [{$jobClass}] (ID: {$jobId})"); + }); + + $this->worker->setOnJobProcessed(function ($job) { + $jobClass = get_class($job); + $jobId = $job->getJobId() ?? 'N/A'; + $this->info("✔ Processed job [{$jobClass}] (ID: {$jobId})"); + + // Flush system output buffer + flush(); + }); + $this->worker->daemon($queue, [ 'sleep' => $sleep, 'maxMemory' => $maxMemory, @@ -79,29 +95,4 @@ protected function handle(): int } }, 'Queue worker stopped gracefully.'); } - - /** - * Get an option value. - * - * @param string $key - * @param mixed $default - * @return mixed - */ - protected function getOption(string $key, $default = null) - { - // Implementation depends on your console command system - // This is a placeholder - adapt to your actual implementation - global $argv; - - foreach ($argv as $i => $arg) { - if (strpos($arg, "--{$key}=") === 0) { - return substr($arg, strlen("--{$key}=")); - } - if ($arg === "--{$key}" && isset($argv[$i + 1])) { - return $argv[$i + 1]; - } - } - - return $default; - } } diff --git a/src/Job.php b/src/Job.php index e9902e8..bc187df 100644 --- a/src/Job.php +++ b/src/Job.php @@ -2,6 +2,7 @@ namespace Doppar\Queue; +use Doppar\Queue\Facades\Queue; use Doppar\Queue\Contracts\JobInterface; abstract class Job implements JobInterface @@ -11,7 +12,7 @@ abstract class Job implements JobInterface * * @var int */ - public $tries = 3; + public $tries = 1; /** * The number of seconds to wait before retrying the job. @@ -145,4 +146,53 @@ public function delayFor(int $delay): self return $this; } + + /** + * Dispatch the job to the queue. + * + * @return string Job ID + */ + public function dispatch(): string + { + return Queue::push($this); + } + + /** + * Dispatch the job to the queue after a delay. + * + * @param int $delay Delay in seconds + * @return string Job ID + */ + public function dispatchAfter(int $delay): string + { + $this->delayFor($delay); + + return $this->dispatch(); + } + + /** + * Dispatch the job to a specific queue. + * + * @param string $queue + * @return string Job ID + */ + public function dispatchOn(string $queue): string + { + $this->onQueue($queue); + + return $this->dispatch(); + } + + /** + * Dispatch the job. + * + * @param mixed ...$args + * @return string Job ID + */ + public static function dispatchNow(...$args): string + { + $job = new static(...$args); + + return $job->dispatch(); + } } diff --git a/src/QueueServiceProvider.php b/src/QueueServiceProvider.php index 86a771e..e6e5408 100644 --- a/src/QueueServiceProvider.php +++ b/src/QueueServiceProvider.php @@ -5,6 +5,11 @@ use Phaseolies\Providers\ServiceProvider; use Doppar\Queue\QueueManager; use Doppar\Queue\Commands\QueueRunCommand; +use Doppar\Queue\Commands\QueueRetryCommand; +use Doppar\Queue\Commands\QueueFlushCommand; +use Doppar\Queue\Commands\QueueFailedCommand; +use Doppar\Queue\Commands\MakeJobCommand; +use Doppar\Queue\Commands\QueueMonitorCommand; class QueueServiceProvider extends ServiceProvider { @@ -32,7 +37,12 @@ public function boot(): void ], 'migrations'); $this->commands([ - QueueRunCommand::class + QueueRunCommand::class, + QueueRetryCommand::class, + MakeJobCommand::class, + QueueFlushCommand::class, + QueueFailedCommand::class, + QueueMonitorCommand::class ]); } } diff --git a/src/QueueWorker.php b/src/QueueWorker.php index 64ba266..f05d850 100644 --- a/src/QueueWorker.php +++ b/src/QueueWorker.php @@ -42,6 +42,21 @@ class QueueWorker */ protected $maxMemory = 128; + /** + * Callback to be executed **before** a job is processed. + * + * @var callable|null + */ + protected $onJobProcessing; + + /** + * Callback to be executed **after** a job has been successfully processed. + * + * + * @var callable|null + */ + protected $onJobProcessed; + /** * Create a new queue worker. * @@ -52,6 +67,28 @@ public function __construct(QueueManager $manager) $this->manager = $manager; } + /** + * Set the callback to be executed **before** a job is processed. + * + * @param callable $callback + * @return void + */ + public function setOnJobProcessing(callable $callback): void + { + $this->onJobProcessing = $callback; + } + + /** + * Set the callback to be executed **after** a job has been processed. + * + * @param callable $callback + * @return void + */ + public function setOnJobProcessed(callable $callback): void + { + $this->onJobProcessed = $callback; + } + /** * Run the worker daemon. * @@ -125,13 +162,20 @@ protected function processJob(QueueJob $queueJob): void $job = $this->manager->unserializeJob($queueJob->payload); $job->attempts = $queueJob->attempts; + if (is_callable($this->onJobProcessing)) { + ($this->onJobProcessing)($job); + } + // Execute the job $this->executeJob($job); // Delete the job from queue if successful $this->manager->delete($queueJob); - $this->logInfo("Job {$job->getJobId()} processed successfully"); + // Trigger onJobProcessed callback + if (is_callable($this->onJobProcessed)) { + ($this->onJobProcessed)($job); + } } catch (\Throwable $e) { $this->handleJobException($queueJob, $job ?? null, $e); } diff --git a/storage/logs/doppar.log b/storage/logs/doppar.log new file mode 100644 index 0000000..c123a1b --- /dev/null +++ b/storage/logs/doppar.log @@ -0,0 +1,9 @@ +[2025-11-14T08:38:24.717063+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:38:57.213083+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:39:33.718229+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:43:01.934544+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:44:10.792443+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:45:01.563845+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:45:05.456173+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:45:57.038190+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. +[2025-11-14T08:47:51.149909+00:00] stack.ERROR: Failed to mark job as failed: Database connection [] not configured. diff --git a/tests/Mock/Jobs/TestComplexDataJob.php b/tests/Mock/Jobs/TestComplexDataJob.php new file mode 100644 index 0000000..7baf003 --- /dev/null +++ b/tests/Mock/Jobs/TestComplexDataJob.php @@ -0,0 +1,22 @@ +data = $data; + } + + public function handle(): void + { + if (empty($this->data)) { + throw new \RuntimeException('No data provided'); + } + } +} diff --git a/tests/Mock/Jobs/TestCounterJob.php b/tests/Mock/Jobs/TestCounterJob.php new file mode 100644 index 0000000..bd36cc7 --- /dev/null +++ b/tests/Mock/Jobs/TestCounterJob.php @@ -0,0 +1,15 @@ +counter++; + } +} diff --git a/tests/Mock/Jobs/TestEmailJob.php b/tests/Mock/Jobs/TestEmailJob.php new file mode 100644 index 0000000..a342725 --- /dev/null +++ b/tests/Mock/Jobs/TestEmailJob.php @@ -0,0 +1,27 @@ +to = $to; + $this->subject = $subject; + } + + public function handle(): void + { + // Simulate sending email + if (empty($this->to) || empty($this->subject)) { + throw new \RuntimeException('Invalid email data'); + } + } +} diff --git a/tests/Mock/Jobs/TestFailingJob.php b/tests/Mock/Jobs/TestFailingJob.php new file mode 100644 index 0000000..13861c2 --- /dev/null +++ b/tests/Mock/Jobs/TestFailingJob.php @@ -0,0 +1,16 @@ +imagePath = $imagePath; + } + + public function handle(): void + { + if (empty($this->imagePath)) { + throw new \RuntimeException('Invalid image path'); + } + } +} diff --git a/tests/Mock/Jobs/TestJobWithFailedCallback.php b/tests/Mock/Jobs/TestJobWithFailedCallback.php new file mode 100644 index 0000000..bb3aa19 --- /dev/null +++ b/tests/Mock/Jobs/TestJobWithFailedCallback.php @@ -0,0 +1,21 @@ +failedCalled = true; + } +} diff --git a/tests/Mock/Jobs/TestReportJob.php b/tests/Mock/Jobs/TestReportJob.php new file mode 100644 index 0000000..1bca22e --- /dev/null +++ b/tests/Mock/Jobs/TestReportJob.php @@ -0,0 +1,24 @@ +reportType = $reportType; + } + + public function handle(): void + { + if (empty($this->reportType)) { + throw new \RuntimeException('Invalid report type'); + } + } +} diff --git a/tests/Mock/MockContainer.php b/tests/Mock/MockContainer.php new file mode 100644 index 0000000..856e4cf --- /dev/null +++ b/tests/Mock/MockContainer.php @@ -0,0 +1,24 @@ +where('queue', $queue) + ->where(function ($q) { + $q->whereNull('reserved_at') + ->orWhere('reserved_at', 0); + }) + ->where('available_at', '<=', time()) + ->orderBy('id', 'asc'); + } + + /** + * Mark the job as reserved. + * + * @return bool + */ + public function reserve(): bool + { + $this->reserved_at = time(); + $this->attempts += 1; + + return $this->save(); + } + + /** + * Release the job back to the queue. + * + * @param int $delay + * @return bool + */ + public function release(int $delay = 0): bool + { + $this->reserved_at = null; + $this->available_at = time() + $delay; + + return $this->save(); + } + + /** + * Delete the job from the queue. + * + * @return bool|null + */ + public function deleteJob(): ?bool + { + return $this->delete(); + } +} diff --git a/tests/Mock/TestQueueManager.php b/tests/Mock/TestQueueManager.php new file mode 100644 index 0000000..0831591 --- /dev/null +++ b/tests/Mock/TestQueueManager.php @@ -0,0 +1,234 @@ +generateJobId(); + $job->setJobId($jobId); + + $payload = $this->createPayload($job); + $availableAt = time() + $job->delay(); + + MockQueueJob::create([ + 'queue' => $job->queue(), + 'payload' => $payload, + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => $availableAt, + 'created_at' => time(), + ]); + + return $jobId; + } catch (\Throwable $e) { + throw new QueueException("Failed to push job to queue: " . $e->getMessage(), 0, $e); + } + } + + /** + * Pop the next job off the queue. + * + * @param string $queue + * @return MockQueueJob|null + */ + public function pop(string $queue = 'default'): ?MockQueueJob + { + try { + $job = MockQueueJob::available($queue)->first(); + + if ($job) { + $job->reserve(); + } + + return $job; + } catch (\Throwable $e) { + return null; + } + } + + /** + * Delete a job from the queue. + * + * @param MockQueueJob $queueJob + * @return bool + */ + public function delete(MockQueueJob $queueJob): bool + { + try { + return $queueJob->deleteJob(); + } catch (\Throwable $e) { + return false; + } + } + + /** + * Release a job back to the queue. + * + * @param MockQueueJob $queueJob + * @param int $delay + * @return bool + */ + public function release(MockQueueJob $queueJob, int $delay = 0): bool + { + try { + return $queueJob->release($delay); + } catch (\Throwable $e) { + return false; + } + } + + /** + * Move a job to the failed jobs table. + * + * @param MockQueueJob $queueJob + * @param \Throwable $exception + * @return void + */ + public function markAsFailed(MockQueueJob $queueJob, \Throwable $exception): void + { + try { + MockFailedJob::create([ + 'connection' => 'database', + 'queue' => $queueJob->queue, + 'payload' => $queueJob->payload, + 'exception' => $this->formatException($exception), + 'failed_at' => time(), + ]); + + $this->delete($queueJob); + } catch (\Throwable $e) { + error("Failed to mark job as failed: " . $e->getMessage()); + } + } + + /** + * Get the count of jobs in a queue. + * + * @param string $queue + * @return int + */ + public function size(string $queue = 'default'): int + { + return MockQueueJob::where('queue', $queue) + ->whereNull('reserved_at') + ->count(); + } + + /** + * Clear all jobs from a queue. + * + * @param string $queue + * @return int Number of jobs deleted + */ + public function clear(string $queue = 'default'): int + { + return MockQueueJob::where('queue', $queue)->delete(); + } + + /** + * Create a payload string from the given job. + * + * @param JobInterface $job + * @return string + */ + protected function createPayload(JobInterface $job): string + { + return serialize([ + 'job' => $job, + 'data' => [ + 'jobId' => $job->getJobId(), + 'queue' => $job->queue(), + 'tries' => $job->tries(), + 'retryAfter' => $job->retryAfter(), + ], + ]); + } + + /** + * Unserialize the job from payload. + * + * @param string $payload + * @return JobInterface + * @throws QueueException + */ + public function unserializeJob(string $payload): JobInterface + { + try { + $data = unserialize($payload); + return $data['job']; + } catch (\Throwable $e) { + throw new QueueException("Failed to unserialize job: " . $e->getMessage(), 0, $e); + } + } + + /** + * Generate a unique job ID. + * + * @return string + */ + protected function generateJobId(): string + { + return uniqid('job_', true); + } + + /** + * Format exception for storage. + * + * @param \Throwable $exception + * @return string + */ + protected function formatException(\Throwable $exception): string + { + return sprintf( + "%s: %s in %s:%d\nStack trace:\n%s", + get_class($exception), + $exception->getMessage(), + $exception->getFile(), + $exception->getLine(), + $exception->getTraceAsString() + ); + } + + /** + * Set the default queue name. + * + * @param string $queue + * @return void + */ + public function setDefaultQueue(string $queue): void + { + $this->defaultQueue = $queue; + } + + /** + * Get the default queue name. + * + * @return string + */ + public function getDefaultQueue(): string + { + return $this->defaultQueue; + } +} diff --git a/tests/Unit/AuthorizationTest.php b/tests/Unit/AuthorizationTest.php deleted file mode 100644 index dceb3e0..0000000 --- a/tests/Unit/AuthorizationTest.php +++ /dev/null @@ -1,205 +0,0 @@ -authorizer = new Authorizer(); - } - - public function testPolicyRegistrationAndResolution() - { - $policy = new class { - public function edit($user, $model) - { - return $user->id === $model->owner_id; - } - }; - - $model = new class { - public $owner_id = 1; - }; - - $user = new class { - public $id = 1; - }; - - $this->authorizer->authorize(get_class($model), get_class($policy)); - $this->assertSame([get_class($model) => get_class($policy)], $this->authorizer->policies()); - } - - public function testAbilityDefinitionAndChecking() - { - $this->authorizer->define('edit-settings', function ($user) { - return $user->isAdmin; - }); - - $adminUser = new class { - public $isAdmin = true; - }; - $regularUser = new class { - public $isAdmin = false; - }; - - $this->authorizer->resolveUserUsing(fn() => $adminUser); - $this->assertTrue($this->authorizer->allows('edit-settings')); - - $this->authorizer->resolveUserUsing(fn() => $regularUser); - $this->assertFalse($this->authorizer->allows('edit-settings')); - $this->assertTrue($this->authorizer->denies('edit-settings')); - } - - public function testTemporaryAbilities() - { - $called = false; - $this->authorizer->temporary('temp-ability', function () use (&$called) { - $called = true; - return true; - }); - - $this->assertTrue($this->authorizer->allows('temp-ability')); - $this->assertTrue($called); - - // Should be removed after first check - $this->assertFalse($this->authorizer->hasAbility('temp-ability')); - } - - public function testAbilityHierarchy() - { - $this->authorizer->define('admin', fn($user) => $user->isAdmin); - $this->authorizer->inherit('admin', ['manage-users', 'manage-settings']); - - $adminUser = new class { - public $isAdmin = true; - }; - $this->authorizer->resolveUserUsing(fn() => $adminUser); - - $this->assertTrue($this->authorizer->allows('manage-users')); - $this->assertTrue($this->authorizer->allows('manage-settings')); - $this->assertSame(['manage-users', 'manage-settings'], $this->authorizer->getChildren('admin')); - } - - public function testAbilityGroups() - { - $this->authorizer->group('content', ['create-post', 'edit-post', 'delete-post']); - $this->assertTrue($this->authorizer->inGroup('content', 'edit-post')); - $this->assertFalse($this->authorizer->inGroup('content', 'manage-users')); - } - - public function testBeforeAndAfterCallbacks() - { - $beforeCalled = false; - $afterCalled = false; - - $this->authorizer->before(function ($user, $ability) use (&$beforeCalled) { - $beforeCalled = true; - return $ability === 'bypass' ? true : null; - }); - - $this->authorizer->after(function ($user, $ability, $result) use (&$afterCalled) { - $afterCalled = true; - }); - - // Before callback should allow this - $this->assertTrue($this->authorizer->allows('bypass')); - $this->assertTrue($beforeCalled); - $this->assertTrue($afterCalled); - - // Reset flags - $beforeCalled = false; - $afterCalled = false; - - // Test with regular ability - $this->authorizer->define('test', fn() => true); - $this->assertTrue($this->authorizer->allows('test')); - $this->assertTrue($beforeCalled); - $this->assertTrue($afterCalled); - } - - public function testPolicyAuthorization() - { - $policy = new class { - public function update($user, $model) - { - return $user->id === $model->owner_id; - } - }; - - $model = new class { - public $owner_id = 1; - }; - - $user = new class { - public $id = 1; - }; - $otherUser = new class { - public $id = 2; - }; - - $this->authorizer->authorize(get_class($model), get_class($policy)); - - $this->authorizer->resolveUserUsing(fn() => $user); - $this->assertTrue($this->authorizer->allows('update', $model)); - - $this->authorizer->resolveUserUsing(fn() => $otherUser); - $this->assertFalse($this->authorizer->allows('update', $model)); - } - - public function testAnyAndAllMethods() - { - $this->authorizer->define('ability1', fn() => true); - $this->authorizer->define('ability2', fn() => false); - $this->authorizer->define('ability3', fn() => true); - - $this->assertTrue($this->authorizer->any(['ability1', 'ability2'])); - $this->assertFalse($this->authorizer->any(['ability2', 'nonexistent'])); - - $this->assertTrue($this->authorizer->all(['ability1', 'ability3'])); - $this->assertFalse($this->authorizer->all(['ability1', 'ability2'])); - } - - public function testHasAbilityAndGetAllAbilities() - { - $this->authorizer->define('defined', fn() => true); - $this->authorizer->temporary('temp', fn() => true); - $this->authorizer->inherit('parent', ['child']); - - $this->assertTrue($this->authorizer->hasAbility('defined')); - $this->assertTrue($this->authorizer->hasAbility('temp')); - $this->assertTrue($this->authorizer->hasAbility('parent')); - $this->assertFalse($this->authorizer->hasAbility('nonexistent')); - - $allAbilities = $this->authorizer->getAllAbilities(); - $this->assertContains('defined', $allAbilities); - $this->assertContains('temp', $allAbilities); - $this->assertContains('parent', $allAbilities); - } - - public function testClearMethod() - { - $this->authorizer->define('test', fn() => true); - $this->authorizer->authorize('Model', 'Policy'); - - $this->assertNotEmpty($this->authorizer->abilities()); - $this->assertNotEmpty($this->authorizer->policies()); - - $this->authorizer->clear(); - - $this->assertEmpty($this->authorizer->abilities()); - $this->assertEmpty($this->authorizer->policies()); - } - - public function testUserResolution() - { - $user = new class {}; - $this->authorizer->resolveUserUsing(fn() => $user); - $this->assertSame($user, $this->authorizer->resolveUser()); - } -} diff --git a/tests/Unit/QueueSystemTest.php b/tests/Unit/QueueSystemTest.php new file mode 100644 index 0000000..112b0c1 --- /dev/null +++ b/tests/Unit/QueueSystemTest.php @@ -0,0 +1,693 @@ +bind('request', fn() => new Request()); + $container->bind('url', fn() => UrlGenerator::class); + $container->bind('db', fn() => new Database('default')); + $container->singleton('queue.worker', TestQueueManager::class); + $container->singleton('log', LoggerService::class); + + $this->pdo = new PDO('sqlite::memory:'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->createQueueTables(); + $this->setupDatabaseConnections(); + + $this->manager = new QueueManager(); + $this->worker = new QueueWorker($this->manager); + } + + protected function tearDown(): void + { + $this->pdo = null; + $this->manager = null; + $this->worker = null; + $this->tearDownDatabaseConnections(); + } + + private function createQueueTables(): void + { + // Create queue_jobs table + $this->pdo->exec(" + CREATE TABLE queue_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + queue TEXT NOT NULL, + payload TEXT NOT NULL, + attempts INTEGER DEFAULT 0, + reserved_at INTEGER, + available_at INTEGER NOT NULL, + created_at INTEGER NOT NULL + ) + "); + + $this->pdo->exec(" + CREATE INDEX idx_queue_reserved ON queue_jobs(queue, reserved_at) + "); + + // Create failed_jobs table + $this->pdo->exec(" + CREATE TABLE failed_jobs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + connection TEXT NOT NULL, + queue TEXT NOT NULL, + payload TEXT NOT NULL, + exception TEXT NOT NULL, + failed_at INTEGER NOT NULL + ) + "); + + $this->pdo->exec(" + CREATE INDEX idx_failed_at ON failed_jobs(failed_at) + "); + } + + private function setupDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + + $this->setStaticProperty(Database::class, 'connections', [ + 'default' => $this->pdo, + 'sqlite' => $this->pdo + ]); + } + + private function tearDownDatabaseConnections(): void + { + $this->setStaticProperty(Database::class, 'connections', []); + $this->setStaticProperty(Database::class, 'transactions', []); + } + + private function setStaticProperty(string $className, string $propertyName, $value): void + { + try { + $reflection = new \ReflectionClass($className); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + $property->setValue(null, $value); + $property->setAccessible(false); + } catch (\ReflectionException $e) { + $this->fail("Failed to set static property {$propertyName}: " . $e->getMessage()); + } + } + + // ===================================================== + // TEST JOB CREATION AND DISPATCHING + // ===================================================== + + public function testPushJobToQueue(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + $jobId = Queue::push($job); + + $this->assertNotEmpty($jobId); + $this->assertStringStartsWith('job_', $jobId); + + // Verify job is in database + $queueJob = MockQueueJob::where('queue', 'default')->first(); + $this->assertNotNull($queueJob); + $this->assertEquals('default', $queueJob->queue); + $this->assertEquals(0, $queueJob->attempts); + } + + public function testPushJobWithCustomQueue(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + $job->onQueue('emails'); + $jobId = Queue::push($job); + + $this->assertNotEmpty($jobId); + + // Verify job is in correct queue + $queueJob = MockQueueJob::where('queue', 'emails')->first(); + $this->assertNotNull($queueJob); + $this->assertEquals('emails', $queueJob->queue); + } + + public function testPushJobWithDelay(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + $job->delayFor(300); // 5 minutes + + $beforeTime = time(); + $jobId = Queue::push($job); + $afterTime = time(); + + $queueJob = MockQueueJob::where('queue', 'default')->first(); + $this->assertNotNull($queueJob); + + // available_at should be current time + 300 seconds + $this->assertGreaterThanOrEqual($beforeTime + 300, $queueJob->available_at); + $this->assertLessThanOrEqual($afterTime + 300, $queueJob->available_at); + } + + // ===================================================== + // TEST JOB RETRIEVAL + // ===================================================== + + public function testPopJobFromQueue(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + Queue::push($job); + + $queueJob = Queue::pop('default'); + + $this->assertNotNull($queueJob); + $this->assertInstanceOf(MockQueueJob::class, $queueJob); + $this->assertEquals(1, $queueJob->attempts); + $this->assertNotNull($queueJob->reserved_at); + } + + public function testPopJobRespectsAvailableAt(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + $job->delayFor(3600); // 1 hour delay + Queue::push($job); + + // Should not pop job that's not available yet + $queueJob = Queue::pop('default'); + $this->assertNull($queueJob); + + // Manually update available_at to make it available + MockQueueJob::where('queue', 'default')->update(['available_at' => time() - 1]); + + // Now it should pop + $queueJob = Queue::pop('default'); + $this->assertNotNull($queueJob); + } + + public function testPopJobFromEmptyQueue(): void + { + $queueJob = $this->manager->pop('default'); + $this->assertNull($queueJob); + } + + public function testPopJobFromSpecificQueue(): void + { + $emailJob = new TestEmailJob('test@example.com', 'Subject'); + $emailJob->onQueue('emails'); + Queue::push($emailJob); + + $imageJob = new TestImageJob('/path/to/image.jpg'); + $imageJob->onQueue('images'); + Queue::push($imageJob); + + // Pop from emails queue + $queueJob = Queue::pop('emails'); + $this->assertNotNull($queueJob); + $this->assertEquals('emails', $queueJob->queue); + + // Pop from images queue + $queueJob = Queue::pop('images'); + $this->assertNotNull($queueJob); + $this->assertEquals('images', $queueJob->queue); + } + + // ===================================================== + // TEST JOB EXECUTION + // ===================================================== + + public function testJobExecutionSuccess(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + Queue::push($job); + + $queueJob = Queue::pop('default'); + $this->assertNotNull($queueJob); + + // Execute the job + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + + // Job should have been executed + $this->assertEquals('test@example.com', $unserializedJob->to); + $this->assertEquals('Test Subject', $unserializedJob->subject); + + // Delete job after successful execution + $deleted = Queue::delete($queueJob); + $this->assertTrue($deleted); + + // Verify job is removed + $count = MockQueueJob::count(); + $this->assertEquals(0, $count); + } + + public function testJobExecutionFailureAndRetry(): void + { + $job = new TestFailingJob(); + $job->tries = 3; + $job->retryAfter = 60; + Queue::push($job); + + // First attempt + $queueJob = Queue::pop('default'); + $this->assertEquals(1, $queueJob->attempts); + + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + $this->fail('Job should have thrown an exception'); + } catch (\Exception $e) { + // Job failed, release it back + $released = Queue::release($queueJob, $job->retryAfter); + $this->assertTrue($released); + } + + // Verify job was released + $queueJob = MockQueueJob::find($queueJob->id); + $this->assertNull($queueJob->reserved_at); + $this->assertEquals(1, $queueJob->attempts); + } + + public function testJobMovedToFailedAfterMaxAttempts(): void + { + $job = new TestFailingJob(); + $job->tries = 2; + Queue::push($job); + + // First attempt + $queueJob = Queue::pop('default'); + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + } catch (\Exception $e) { + Queue::release($queueJob, 0); + } + + // Make job available immediately + MockQueueJob::where('id', $queueJob->id)->update(['available_at' => time() - 1]); + + // Second attempt + $queueJob = Queue::pop('default'); + $this->assertEquals(2, $queueJob->attempts); + + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + } catch (\Exception $e) { + // Max attempts reached, mark as failed + Queue::markAsFailed($queueJob, $e); + } + + // Verify job is in failed_jobs table + $failedJob = MockFailedJob::where('queue', 'default')->first(); + $this->assertNotNull($failedJob); + $this->assertStringContainsString('Test failure', $failedJob->exception); + + // Verify job is removed from queue_jobs + $queueJob = MockQueueJob::find($queueJob->id); + $this->assertNull($queueJob); + } + + // ===================================================== + // TEST QUEUE OPERATIONS + // ===================================================== + + public function testQueueSize(): void + { + $job1 = new TestEmailJob('test1@example.com', 'Subject 1'); + $job2 = new TestEmailJob('test2@example.com', 'Subject 2'); + $job3 = new TestEmailJob('test3@example.com', 'Subject 3'); + + Queue::push($job1); + Queue::push($job2); + Queue::push($job3); + + $size = Queue::size('default'); + $this->assertEquals(3, $size); + } + + public function testQueueClear(): void + { + $job1 = new TestEmailJob('test1@example.com', 'Subject 1'); + $job2 = new TestEmailJob('test2@example.com', 'Subject 2'); + $job3 = new TestEmailJob('test3@example.com', 'Subject 3'); + + Queue::push($job1); + Queue::push($job2); + Queue::push($job3); + + $deleted = Queue::clear('default'); + $this->assertEquals(1, $deleted); + + $size = Queue::size('default'); + $this->assertEquals(0, $size); + } + + public function testClearSpecificQueue(): void + { + $emailJob = new TestEmailJob('test@example.com', 'Subject'); + $emailJob->onQueue('emails'); + Queue::push($emailJob); + + $imageJob = new TestImageJob('/path/to/image.jpg'); + $imageJob->onQueue('images'); + Queue::push($imageJob); + + // Clear only emails queue + $deleted = Queue::clear('emails'); + $this->assertEquals(1, $deleted); + + // Verify images queue is intact + $size = Queue::size('images'); + $this->assertEquals(1, $size); + } + + // ===================================================== + // TEST JOB SERIALIZATION + // ===================================================== + + public function testJobSerialization(): void + { + $job = new TestEmailJob('test@example.com', 'Test Subject'); + $job->setJobId('test_job_123'); + $jobId = Queue::push($job); + + $queueJob = MockQueueJob::where('queue', 'default')->first(); + $this->assertNotNull($queueJob); + + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + + $this->assertInstanceOf(TestEmailJob::class, $unserializedJob); + $this->assertEquals('test@example.com', $unserializedJob->to); + $this->assertEquals('Test Subject', $unserializedJob->subject); + } + + public function testJobSerializationWithComplexData(): void + { + $job = new TestComplexDataJob([ + 'user' => ['id' => 1, 'name' => 'John Doe'], + 'settings' => ['timezone' => 'UTC', 'theme' => 'dark'], + 'tags' => ['php', 'laravel', 'queue'] + ]); + Queue::push($job); + + $queueJob = MockQueueJob::where('queue', 'default')->first(); + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + + $this->assertEquals('John Doe', $unserializedJob->data['user']['name']); + $this->assertEquals(['php', 'laravel', 'queue'], $unserializedJob->data['tags']); + } + + // ===================================================== + // TEST FAILED JOBS + // ===================================================== + + public function testFailedJobStorage(): void + { + $job = new TestFailingJob(); + Queue::push($job); + + $queueJob = Queue::pop('default'); + + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + } catch (\Exception $e) { + Queue::markAsFailed($queueJob, $e); + } + + $failedJob = MockFailedJob::first(); + $this->assertNotNull($failedJob); + $this->assertEquals('database', $failedJob->connection); + $this->assertEquals('default', $failedJob->queue); + $this->assertStringContainsString('RuntimeException', $failedJob->exception); + $this->assertStringContainsString('Test failure', $failedJob->exception); + } + + public function testFailedJobCallback(): void + { + $job = new TestJobWithFailedCallback(); + Queue::push($job); + + $queueJob = Queue::pop('default'); + + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + } catch (\Exception $e) { + Queue::markAsFailed($queueJob, $e); + $unserializedJob->failed($e); + } + + // The failed callback should have been called + $this->assertTrue($unserializedJob->failedCalled); + } + + // ===================================================== + // TEST QUEUE MODELS + // ===================================================== + + public function testQueueJobModel(): void + { + $job = new TestEmailJob('test@example.com', 'Subject'); + Queue::push($job); + + $queueJob = MockQueueJob::where('queue', 'default')->first(); + + $this->assertInstanceOf(MockQueueJob::class, $queueJob); + $this->assertEquals('default', $queueJob->queue); + $this->assertEquals(0, $queueJob->attempts); + $this->assertNull($queueJob->reserved_at); + } + + public function testQueueJobReserve(): void + { + $job = new TestEmailJob('test@example.com', 'Subject'); + Queue::push($job); + + $queueJob = MockQueueJob::available('default')->first(); + $this->assertNotNull($queueJob); + + $reserved = $queueJob->reserve(); + $this->assertTrue($reserved); + $this->assertEquals(1, $queueJob->attempts); + $this->assertNotNull($queueJob->reserved_at); + } + + public function testQueueJobRelease(): void + { + $job = new TestEmailJob('test@example.com', 'Subject'); + Queue::push($job); + + $queueJob = MockQueueJob::available('default')->first(); + $queueJob->reserve(); + + $released = $queueJob->release(60); + $this->assertTrue($released); + $this->assertNull($queueJob->reserved_at); + $this->assertGreaterThan(time(), $queueJob->available_at); + } + + public function testFailedJobModel(): void + { + $failedJob = MockFailedJob::create([ + 'connection' => 'database', + 'queue' => 'default', + 'payload' => serialize(['test' => 'data']), + 'exception' => 'Test exception', + 'failed_at' => time(), + ]); + + $this->assertInstanceOf(MockFailedJob::class, $failedJob); + $this->assertEquals('database', $failedJob->connection); + $this->assertEquals('default', $failedJob->queue); + } + + // ===================================================== + // TEST WORKER BEHAVIOR + // ===================================================== + + public function testWorkerProcessSingleJob(): void + { + $job = new TestCounterJob(); + Queue::push($job); + + // Process one job + $queueJob = Queue::pop('default'); + $this->assertNotNull($queueJob); + + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + + $this->assertEquals(1, $unserializedJob->counter); + + // Delete job + Queue::delete($queueJob); + + // Queue should be empty + $this->assertEquals(0, Queue::size('default')); + } + + public function testWorkerMemoryCheck(): void + { + $this->worker->setMaxMemory(1); // 1MB limit + + // This should return true since we're using more than 1MB + $reflection = new \ReflectionClass($this->worker); + $method = $reflection->getMethod('memoryExceeded'); + $method->setAccessible(true); + + $exceeded = $method->invoke($this->worker); + $this->assertTrue($exceeded); + } + + // ===================================================== + // TEST MULTIPLE QUEUES + // ===================================================== + + public function testMultipleQueues(): void + { + // Create jobs on different queues + $emailJob = new TestEmailJob('test@example.com', 'Subject'); + $emailJob->onQueue('emails'); + Queue::push($emailJob); + + $imageJob = new TestImageJob('/path/to/image.jpg'); + $imageJob->onQueue('images'); + Queue::push($imageJob); + + $reportJob = new TestReportJob('monthly'); + $reportJob->onQueue('reports'); + Queue::push($reportJob); + + // Verify each queue has correct job + $this->assertEquals(1, Queue::size('emails')); + $this->assertEquals(1, Queue::size('images')); + $this->assertEquals(1, Queue::size('reports')); + $this->assertEquals(0, Queue::size('default')); + } + + public function testJobWithZeroRetries(): void + { + $job = new TestFailingJob(); + $job->tries = 0; // No retries + Queue::push($job); + + $queueJob = Queue::pop('default'); + + try { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + } catch (\Exception $e) { + // Should mark as failed immediately + Queue::markAsFailed($queueJob, $e); + } + + $failedJob = MockFailedJob::first(); + $this->assertNotNull($failedJob); + } + + public function testJobIdGeneration(): void + { + $job1 = new TestEmailJob('test1@example.com', 'Subject 1'); + $job2 = new TestEmailJob('test2@example.com', 'Subject 2'); + + $jobId1 = Queue::push($job1); + $jobId2 = Queue::push($job2); + + $this->assertNotEquals($jobId1, $jobId2); + $this->assertStringStartsWith('job_', $jobId1); + $this->assertStringStartsWith('job_', $jobId2); + } + + // ===================================================== + // TEST QUERY BINDING + // ===================================================== + + public function testAvailableJobsScope(): void + { + // Create two available jobs + $job1 = new TestEmailJob('test1@example.com', 'Subject 1'); + Queue::push($job1); + + $job2 = new TestEmailJob('test2@example.com', 'Subject 2'); + Queue::push($job2); + + // Create delayed job (not available yet) + $job3 = new TestEmailJob('test3@example.com', 'Subject 3'); + $job3->delayFor(3600); + Queue::push($job3); + + // Before popping: 2 available jobs (job1 and job2), 1 delayed (job3) + $available = MockQueueJob::available('default')->count(); + $this->assertEquals(2, $available); + + // Pop one job (makes it reserved) + Queue::pop('default'); + + // After popping: 1 available job (job2), 1 reserved (job1), 1 delayed (job3) + $available = MockQueueJob::available('default')->count(); + $this->assertEquals(1, $available); + + // Verify total jobs in database + $total = MockQueueJob::where('queue', 'default')->count(); + $this->assertEquals(3, $total); + } + + // ===================================================== + // TEST INTEGRATION + // ===================================================== + + public function testEndToEndJobProcessing(): void + { + // Create multiple jobs + $jobs = [ + new TestEmailJob('user1@example.com', 'Welcome'), + new TestEmailJob('user2@example.com', 'Newsletter'), + new TestEmailJob('user3@example.com', 'Update'), + ]; + + foreach ($jobs as $job) { + Queue::push($job); + } + + $this->assertEquals(3, Queue::size('default')); + + // Process all jobs + $processed = 0; + while ($queueJob = Queue::pop('default')) { + $unserializedJob = $this->manager->unserializeJob($queueJob->payload); + $unserializedJob->handle(); + Queue::delete($queueJob); + $processed++; + } + + $this->assertEquals(3, $processed); + $this->assertEquals(0, Queue::size('default')); + $this->assertEquals(0, MockFailedJob::count()); + } +}