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/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()); + } +}