docs: consolidate documentation into organized structure

- Move 12 markdown files from root to docs/ subdirectories
- Organize documentation by category:
  • docs/troubleshooting/ (1 file)  - Technical troubleshooting guides
  • docs/deployment/      (4 files) - Deployment and security documentation
  • docs/guides/          (3 files) - Feature-specific guides
  • docs/planning/        (4 files) - Planning and improvement proposals

Root directory cleanup:
- Reduced from 16 to 4 markdown files in root
- Only essential project files remain:
  • CLAUDE.md (AI instructions)
  • README.md (Main project readme)
  • CLEANUP_PLAN.md (Current cleanup plan)
  • SRC_STRUCTURE_IMPROVEMENTS.md (Structure improvements)

This improves:
 Documentation discoverability
 Logical organization by purpose
 Clean root directory
 Better maintainability
This commit is contained in:
2025-10-05 11:05:04 +02:00
parent 887847dde6
commit 5050c7d73a
36686 changed files with 196456 additions and 12398919 deletions

View File

@@ -0,0 +1,617 @@
<?php
declare(strict_types=1);
use App\Framework\Queue\ValueObjects\DeadLetterQueueName;
use App\Framework\Queue\ValueObjects\QueueName;
use App\Framework\Queue\ValueObjects\JobId;
use App\Framework\Queue\ValueObjects\JobPayload;
use App\Framework\Queue\ValueObjects\QueuePriority;
use App\Framework\Queue\ValueObjects\FailureReason;
use App\Framework\Queue\Exceptions\InvalidDeadLetterQueueNameException;
describe('DeadLetterQueueName Value Object', function () {
describe('Construction and Validation', function () {
it('can create valid dead letter queue names', function () {
$validNames = [
'failed',
'email_failed',
'background-jobs-failed',
'user.registration.failed',
'queue_123_failed',
'a', // minimum length
str_repeat('a', 100) // maximum length
];
foreach ($validNames as $name) {
$dlqName = DeadLetterQueueName::fromString($name);
expect($dlqName->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<invalid>',
'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));
});
});