toString())->toBe($name); } }); it('rejects invalid dead letter queue names', function () { $invalidNames = [ '', // too short str_repeat('a', 101), // too long 'queue with spaces', 'queue@invalid', 'queue#invalid', 'queue$invalid', 'queue%invalid', 'queue&invalid', 'queue*invalid', 'queue(invalid)', 'queue+invalid', 'queue=invalid', 'queue[invalid]', 'queue{invalid}', 'queue|invalid', 'queue\\invalid', 'queue:invalid', 'queue;invalid', 'queue"invalid', 'queue\'invalid', 'queue', 'queue,invalid', 'queue?invalid', 'queue/invalid', 'queue~invalid', 'queue`invalid', ]; foreach ($invalidNames as $name) { expect(fn () => DeadLetterQueueName::fromString($name)) ->toThrow(InvalidDeadLetterQueueNameException::class); } }); it('is readonly and immutable', function () { $dlqName = DeadLetterQueueName::fromString('test-failed'); $reflection = new ReflectionClass($dlqName); expect($reflection->isReadOnly())->toBeTrue(); $nameProperty = $reflection->getProperty('name'); expect($nameProperty->isReadOnly())->toBeTrue(); }); }); describe('Factory Methods', function () { it('creates default dead letter queue name', function () { $defaultDlq = DeadLetterQueueName::default(); expect($defaultDlq->toString())->toBe('failed'); }); it('creates dead letter queue for specific queue', function () { $queueName = QueueName::fromString('email'); $dlqName = DeadLetterQueueName::forQueue($queueName); expect($dlqName->toString())->toBe('email_failed'); }); it('creates dead letter queue for complex queue names', function () { $queueName = QueueName::fromString('user.registration'); $dlqName = DeadLetterQueueName::forQueue($queueName); expect($dlqName->toString())->toBe('user.registration_failed'); }); }); describe('Equality and Comparison', function () { it('equals() compares names correctly', function () { $dlq1 = DeadLetterQueueName::fromString('failed'); $dlq2 = DeadLetterQueueName::fromString('failed'); $dlq3 = DeadLetterQueueName::fromString('other_failed'); expect($dlq1->equals($dlq2))->toBeTrue(); expect($dlq1->equals($dlq3))->toBeFalse(); expect($dlq2->equals($dlq3))->toBeFalse(); }); it('string representation works correctly', function () { $name = 'test-failed-queue'; $dlqName = DeadLetterQueueName::fromString($name); expect($dlqName->toString())->toBe($name); expect((string) $dlqName)->toBe($name); }); }); describe('Edge Cases', function () { it('handles minimum and maximum length names', function () { $minName = DeadLetterQueueName::fromString('a'); expect($minName->toString())->toBe('a'); $maxName = DeadLetterQueueName::fromString(str_repeat('x', 100)); expect($maxName->toString())->toBe(str_repeat('x', 100)); }); it('handles all valid characters', function () { $validChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.'; $dlqName = DeadLetterQueueName::fromString($validChars); expect($dlqName->toString())->toBe($validChars); }); it('provides specific error messages for validation failures', function () { // Too short try { DeadLetterQueueName::fromString(''); expect(false)->toBeTrue('Should have thrown exception'); } catch (InvalidDeadLetterQueueNameException $e) { expect($e->getMessage())->toContain('too short'); } // Too long try { DeadLetterQueueName::fromString(str_repeat('a', 101)); expect(false)->toBeTrue('Should have thrown exception'); } catch (InvalidDeadLetterQueueNameException $e) { expect($e->getMessage())->toContain('too long'); } // Invalid format try { DeadLetterQueueName::fromString('invalid@name'); expect(false)->toBeTrue('Should have thrown exception'); } catch (InvalidDeadLetterQueueNameException $e) { expect($e->getMessage())->toContain('invalid format'); } }); }); }); describe('Dead Letter Queue Mock Implementation', function () { beforeEach(function () { // Create a mock dead letter queue for testing $this->mockDlq = new class () { private array $jobs = []; private array $stats = []; public function addFailedJob(array $jobData): void { $this->jobs[] = $jobData; } public function getJobs(DeadLetterQueueName $queueName, int $limit = 100): array { return array_slice( array_filter($this->jobs, fn ($job) => $job['dlq_name'] === $queueName->toString()), 0, $limit ); } public function retryJob(string $jobId): bool { foreach ($this->jobs as $index => $job) { if ($job['id'] === $jobId) { unset($this->jobs[$index]); return true; } } return false; } public function deleteJob(string $jobId): bool { foreach ($this->jobs as $index => $job) { if ($job['id'] === $jobId) { unset($this->jobs[$index]); return true; } } return false; } public function clearQueue(DeadLetterQueueName $queueName): int { $initialCount = count($this->jobs); $this->jobs = array_filter( $this->jobs, fn ($job) => $job['dlq_name'] !== $queueName->toString() ); return $initialCount - count($this->jobs); } public function getQueueStats(DeadLetterQueueName $queueName): array { $jobs = $this->getJobs($queueName); return [ 'queue_name' => $queueName->toString(), 'total_jobs' => count($jobs), 'avg_failed_attempts' => count($jobs) > 0 ? array_sum(array_column($jobs, 'failed_attempts')) / count($jobs) : 0, 'max_failed_attempts' => count($jobs) > 0 ? max(array_column($jobs, 'failed_attempts')) : 0, 'avg_retry_count' => count($jobs) > 0 ? array_sum(array_column($jobs, 'retry_count')) / count($jobs) : 0, 'max_retry_count' => count($jobs) > 0 ? max(array_column($jobs, 'retry_count')) : 0, 'oldest_job' => count($jobs) > 0 ? min(array_column($jobs, 'failed_at')) : null, 'newest_job' => count($jobs) > 0 ? max(array_column($jobs, 'failed_at')) : null, ]; } public function getAvailableQueues(): array { $queueNames = array_unique(array_column($this->jobs, 'dlq_name')); return array_map(fn ($name) => DeadLetterQueueName::fromString($name), $queueNames); } }; }); describe('Dead Letter Queue Operations', function () { it('can add failed jobs', function () { $dlqName = DeadLetterQueueName::fromString('email_failed'); $jobId = JobId::generate(); $jobData = [ 'id' => uniqid(), 'original_job_id' => $jobId->toString(), 'dlq_name' => $dlqName->toString(), 'original_queue' => 'email', 'job_payload' => serialize(['type' => 'email', 'data' => 'test']), 'failure_reason' => 'Connection timeout', 'exception_type' => 'TimeoutException', 'stack_trace' => 'Stack trace here...', 'failed_attempts' => 3, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]; $this->mockDlq->addFailedJob($jobData); $jobs = $this->mockDlq->getJobs($dlqName); expect(count($jobs))->toBe(1); expect($jobs[0]['original_job_id'])->toBe($jobId->toString()); }); it('can retrieve jobs by queue name', function () { $emailDlq = DeadLetterQueueName::fromString('email_failed'); $reportDlq = DeadLetterQueueName::fromString('report_failed'); // Add jobs to different queues $this->mockDlq->addFailedJob([ 'id' => 'job1', 'original_job_id' => 'original1', 'dlq_name' => $emailDlq->toString(), 'original_queue' => 'email', 'job_payload' => 'payload1', 'failure_reason' => 'Error 1', 'exception_type' => 'Exception', 'stack_trace' => 'trace1', 'failed_attempts' => 1, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); $this->mockDlq->addFailedJob([ 'id' => 'job2', 'original_job_id' => 'original2', 'dlq_name' => $reportDlq->toString(), 'original_queue' => 'report', 'job_payload' => 'payload2', 'failure_reason' => 'Error 2', 'exception_type' => 'Exception', 'stack_trace' => 'trace2', 'failed_attempts' => 2, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); $emailJobs = $this->mockDlq->getJobs($emailDlq); $reportJobs = $this->mockDlq->getJobs($reportDlq); expect(count($emailJobs))->toBe(1); expect(count($reportJobs))->toBe(1); expect($emailJobs[0]['id'])->toBe('job1'); expect($reportJobs[0]['id'])->toBe('job2'); }); it('can retry failed jobs', function () { $dlqName = DeadLetterQueueName::fromString('test_failed'); $jobId = 'retry_test_job'; $this->mockDlq->addFailedJob([ 'id' => $jobId, 'original_job_id' => 'original_retry', 'dlq_name' => $dlqName->toString(), 'original_queue' => 'test', 'job_payload' => 'retry_payload', 'failure_reason' => 'Temporary error', 'exception_type' => 'TemporaryException', 'stack_trace' => 'retry_trace', 'failed_attempts' => 1, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); expect(count($this->mockDlq->getJobs($dlqName)))->toBe(1); $retryResult = $this->mockDlq->retryJob($jobId); expect($retryResult)->toBeTrue(); // Job should be removed from DLQ after retry expect(count($this->mockDlq->getJobs($dlqName)))->toBe(0); }); it('can delete failed jobs permanently', function () { $dlqName = DeadLetterQueueName::fromString('delete_test'); $jobId = 'delete_test_job'; $this->mockDlq->addFailedJob([ 'id' => $jobId, 'original_job_id' => 'original_delete', 'dlq_name' => $dlqName->toString(), 'original_queue' => 'delete_test', 'job_payload' => 'delete_payload', 'failure_reason' => 'Permanent error', 'exception_type' => 'PermanentException', 'stack_trace' => 'delete_trace', 'failed_attempts' => 5, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); expect(count($this->mockDlq->getJobs($dlqName)))->toBe(1); $deleteResult = $this->mockDlq->deleteJob($jobId); expect($deleteResult)->toBeTrue(); expect(count($this->mockDlq->getJobs($dlqName)))->toBe(0); }); it('can clear entire queue', function () { $dlqName = DeadLetterQueueName::fromString('clear_test'); // Add multiple jobs for ($i = 1; $i <= 5; $i++) { $this->mockDlq->addFailedJob([ 'id' => "clear_job_{$i}", 'original_job_id' => "original_{$i}", 'dlq_name' => $dlqName->toString(), 'original_queue' => 'clear_test', 'job_payload' => "payload_{$i}", 'failure_reason' => "Error {$i}", 'exception_type' => 'Exception', 'stack_trace' => "trace_{$i}", 'failed_attempts' => $i, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); } expect(count($this->mockDlq->getJobs($dlqName)))->toBe(5); $clearedCount = $this->mockDlq->clearQueue($dlqName); expect($clearedCount)->toBe(5); expect(count($this->mockDlq->getJobs($dlqName)))->toBe(0); }); }); describe('Dead Letter Queue Statistics', function () { it('provides accurate queue statistics', function () { $dlqName = DeadLetterQueueName::fromString('stats_test'); // Add jobs with varying statistics $jobs = [ ['failed_attempts' => 1, 'retry_count' => 0], ['failed_attempts' => 3, 'retry_count' => 1], ['failed_attempts' => 5, 'retry_count' => 2], ['failed_attempts' => 2, 'retry_count' => 0], ]; foreach ($jobs as $index => $jobStats) { $this->mockDlq->addFailedJob([ 'id' => "stats_job_{$index}", 'original_job_id' => "original_stats_{$index}", 'dlq_name' => $dlqName->toString(), 'original_queue' => 'stats_test', 'job_payload' => "payload_{$index}", 'failure_reason' => "Error {$index}", 'exception_type' => 'Exception', 'stack_trace' => "trace_{$index}", 'failed_attempts' => $jobStats['failed_attempts'], 'failed_at' => date('Y-m-d H:i:s', time() - $index * 3600), // Different times 'retry_count' => $jobStats['retry_count'], ]); } $stats = $this->mockDlq->getQueueStats($dlqName); expect($stats['queue_name'])->toBe($dlqName->toString()); expect($stats['total_jobs'])->toBe(4); expect($stats['avg_failed_attempts'])->toBe(2.75); // (1+3+5+2)/4 expect($stats['max_failed_attempts'])->toBe(5); expect($stats['avg_retry_count'])->toBe(0.75); // (0+1+2+0)/4 expect($stats['max_retry_count'])->toBe(2); expect($stats['oldest_job'])->not->toBeNull(); expect($stats['newest_job'])->not->toBeNull(); }); it('handles empty queue statistics', function () { $dlqName = DeadLetterQueueName::fromString('empty_stats'); $stats = $this->mockDlq->getQueueStats($dlqName); expect($stats['total_jobs'])->toBe(0); expect($stats['avg_failed_attempts'])->toBe(0); expect($stats['max_failed_attempts'])->toBe(0); expect($stats['avg_retry_count'])->toBe(0); expect($stats['max_retry_count'])->toBe(0); expect($stats['oldest_job'])->toBeNull(); expect($stats['newest_job'])->toBeNull(); }); it('can list available queues', function () { $queues = [ DeadLetterQueueName::fromString('email_failed'), DeadLetterQueueName::fromString('report_failed'), DeadLetterQueueName::fromString('background_failed'), ]; foreach ($queues as $index => $queue) { $this->mockDlq->addFailedJob([ 'id' => "queue_job_{$index}", 'original_job_id' => "original_{$index}", 'dlq_name' => $queue->toString(), 'original_queue' => 'test', 'job_payload' => "payload_{$index}", 'failure_reason' => "Error {$index}", 'exception_type' => 'Exception', 'stack_trace' => "trace_{$index}", 'failed_attempts' => 1, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); } $availableQueues = $this->mockDlq->getAvailableQueues(); expect(count($availableQueues))->toBe(3); $queueNames = array_map(fn ($q) => $q->toString(), $availableQueues); expect($queueNames)->toContain('email_failed'); expect($queueNames)->toContain('report_failed'); expect($queueNames)->toContain('background_failed'); }); }); describe('Dead Letter Queue Error Scenarios', function () { it('handles retry of non-existent job', function () { $result = $this->mockDlq->retryJob('non_existent_job'); expect($result)->toBeFalse(); }); it('handles delete of non-existent job', function () { $result = $this->mockDlq->deleteJob('non_existent_job'); expect($result)->toBeFalse(); }); it('handles clear of non-existent queue', function () { $nonExistentQueue = DeadLetterQueueName::fromString('non_existent'); $clearedCount = $this->mockDlq->clearQueue($nonExistentQueue); expect($clearedCount)->toBe(0); }); it('respects job limit when retrieving jobs', function () { $dlqName = DeadLetterQueueName::fromString('limit_test'); // Add 10 jobs for ($i = 1; $i <= 10; $i++) { $this->mockDlq->addFailedJob([ 'id' => "limit_job_{$i}", 'original_job_id' => "original_{$i}", 'dlq_name' => $dlqName->toString(), 'original_queue' => 'limit_test', 'job_payload' => "payload_{$i}", 'failure_reason' => "Error {$i}", 'exception_type' => 'Exception', 'stack_trace' => "trace_{$i}", 'failed_attempts' => 1, 'failed_at' => date('Y-m-d H:i:s'), 'retry_count' => 0, ]); } $limitedJobs = $this->mockDlq->getJobs($dlqName, 5); expect(count($limitedJobs))->toBe(5); $allJobs = $this->mockDlq->getJobs($dlqName, 100); expect(count($allJobs))->toBe(10); }); }); }); describe('Dead Letter Queue Integration Scenarios', function () { beforeEach(function () { $this->testJob = new class () { public function __construct( public string $email = 'test@example.com', public string $subject = 'Test Email' ) { } public function handle(): bool { // Simulate processing that might fail if ($this->email === 'invalid@test.com') { throw new \Exception('Invalid email address'); } return true; } }; $this->failureScenarios = [ 'network_timeout' => [ 'reason' => 'Network connection timeout', 'exception' => 'NetworkTimeoutException', 'retryable' => true, ], 'invalid_data' => [ 'reason' => 'Invalid email format', 'exception' => 'ValidationException', 'retryable' => false, ], 'service_unavailable' => [ 'reason' => 'External service unavailable', 'exception' => 'ServiceUnavailableException', 'retryable' => true, ], 'permission_denied' => [ 'reason' => 'Insufficient permissions', 'exception' => 'PermissionException', 'retryable' => false, ], ]; }); it('handles different types of job failures', function () { $emailDlq = DeadLetterQueueName::fromString('email_failed'); foreach ($this->failureScenarios as $scenarioName => $scenario) { $jobPayload = JobPayload::create($this->testJob); $jobId = JobId::generate(); // Simulate job failure based on scenario $expectedRetryable = $scenario['retryable']; expect($scenario['reason'])->toBeString(); expect($scenario['exception'])->toBeString(); expect($expectedRetryable)->toBeBool(); } }); it('demonstrates retry strategies for different failure types', function () { $retryableFailures = array_filter( $this->failureScenarios, fn ($scenario) => $scenario['retryable'] ); $nonRetryableFailures = array_filter( $this->failureScenarios, fn ($scenario) => ! $scenario['retryable'] ); expect(count($retryableFailures))->toBeGreaterThan(0); expect(count($nonRetryableFailures))->toBeGreaterThan(0); // Retryable failures should be candidates for retry foreach ($retryableFailures as $scenario) { expect($scenario['retryable'])->toBeTrue(); } // Non-retryable failures should be handled differently foreach ($nonRetryableFailures as $scenario) { expect($scenario['retryable'])->toBeFalse(); } }); it('demonstrates queue-specific dead letter handling', function () { $queueTypes = [ 'email' => DeadLetterQueueName::forQueue(QueueName::fromString('email')), 'reports' => DeadLetterQueueName::forQueue(QueueName::fromString('reports')), 'background' => DeadLetterQueueName::forQueue(QueueName::fromString('background')), ]; foreach ($queueTypes as $originalQueue => $dlqName) { expect($dlqName->toString())->toBe($originalQueue . '_failed'); } // Each queue type should have its own DLQ $queueNames = array_map(fn ($dlq) => $dlq->toString(), $queueTypes); $uniqueNames = array_unique($queueNames); expect(count($uniqueNames))->toBe(count($queueTypes)); }); });